From bcd363a4421e17f67976eccc6df26bbf17503930 Mon Sep 17 00:00:00 2001 From: Alejandro Munoz Date: Sun, 21 Dec 2025 15:17:08 -0500 Subject: [PATCH 1/9] Add similarity service to publish extension and namespace workflow (#1501) * Add similarity service to publish extension workflow * Add javadoc to similarity configuration * Refactor similarity service to allow reuse independent of publishing check configuration --- server/src/dev/resources/application.yml | 7 + .../eclipse/openvsx/LocalRegistryService.java | 19 +- .../PublishExtensionVersionHandler.java | 56 +++- .../repositories/ExtensionJooqRepository.java | 167 ++++++++++ .../repositories/NamespaceJooqRepository.java | 78 +++++ .../repositories/RepositoryService.java | 36 +++ .../search/SimilarityCheckService.java | 119 ++++++++ .../openvsx/search/SimilarityConfig.java | 126 ++++++++ .../openvsx/search/SimilarityService.java | 107 +++++++ .../V1_58__Enable_Fuzzystrmatch_Extension.sql | 12 + .../openvsx/LocalRegistryServiceTest.java | 191 ++++++++++++ .../org/eclipse/openvsx/RegistryAPITest.java | 63 +++- .../java/org/eclipse/openvsx/UserAPITest.java | 28 +- .../eclipse/openvsx/admin/AdminAPITest.java | 28 +- .../PublishExtensionVersionHandlerTest.java | 259 ++++++++++++++++ .../RepositoryServiceSmokeTest.java | 4 +- .../search/SimilarityCheckServiceTest.java | 289 ++++++++++++++++++ .../openvsx/search/SimilarityServiceTest.java | 114 +++++++ 18 files changed, 1685 insertions(+), 18 deletions(-) create mode 100644 server/src/main/java/org/eclipse/openvsx/search/SimilarityCheckService.java create mode 100644 server/src/main/java/org/eclipse/openvsx/search/SimilarityConfig.java create mode 100644 server/src/main/java/org/eclipse/openvsx/search/SimilarityService.java create mode 100644 server/src/main/resources/db/migration/V1_58__Enable_Fuzzystrmatch_Extension.sql create mode 100644 server/src/test/java/org/eclipse/openvsx/LocalRegistryServiceTest.java create mode 100644 server/src/test/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandlerTest.java create mode 100644 server/src/test/java/org/eclipse/openvsx/search/SimilarityCheckServiceTest.java create mode 100644 server/src/test/java/org/eclipse/openvsx/search/SimilarityServiceTest.java diff --git a/server/src/dev/resources/application.yml b/server/src/dev/resources/application.yml index dcce629a4..81e51cfef 100644 --- a/server/src/dev/resources/application.yml +++ b/server/src/dev/resources/application.yml @@ -170,3 +170,10 @@ ovsx: revoked-access-tokens: subject: 'Open VSX Access Tokens Revoked' template: 'revoked-access-tokens.html' + similarity: + enabled: true + levenshtein-threshold: 0.2 + skip-verified-publishers: true + check-against-verified-only: true + exclude-owner-namespaces: true + new-extensions-only: true \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java b/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java index 85eecef5b..13cc8976c 100644 --- a/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java +++ b/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java @@ -23,6 +23,7 @@ import org.eclipse.openvsx.search.ISearchService; import org.eclipse.openvsx.search.SearchResult; import org.eclipse.openvsx.search.SearchUtilService; +import org.eclipse.openvsx.search.SimilarityCheckService; import org.eclipse.openvsx.storage.StorageUtilService; import org.eclipse.openvsx.util.*; import org.slf4j.Logger; @@ -63,6 +64,7 @@ public class LocalRegistryService implements IExtensionRegistry { private final EclipseService eclipse; private final CacheService cache; private final ExtensionVersionIntegrityService integrityService; + private final SimilarityCheckService similarityCheckService; public LocalRegistryService( EntityManager entityManager, @@ -75,7 +77,8 @@ public LocalRegistryService( StorageUtilService storageUtil, EclipseService eclipse, CacheService cache, - ExtensionVersionIntegrityService integrityService + ExtensionVersionIntegrityService integrityService, + SimilarityCheckService similarityCheckService ) { this.entityManager = entityManager; this.repositories = repositories; @@ -88,6 +91,7 @@ public LocalRegistryService( this.eclipse = eclipse; this.cache = cache; this.integrityService = integrityService; + this.similarityCheckService = similarityCheckService; } @Value("${ovsx.webui.url:}") @@ -595,6 +599,19 @@ public ResultJson createNamespace(NamespaceJson json, UserData user) { throw new ErrorResultException("Namespace already exists: " + namespaceName); } + // Check if the proposed namespace name is too similar to existing ones + var similarNamespaces = similarityCheckService.findSimilarNamespacesForCreation(json.getName(), user); + if (!similarNamespaces.isEmpty()) { + var similarNames = similarNamespaces.stream() + .map(Namespace::getName) + .collect(Collectors.joining(", ")); + throw new ErrorResultException( + "Namespace name '" + json.getName() + "' is too similar to existing namespace(s): " + similarNames + ". " + + "Please choose a more distinct name to avoid confusion. " + + "Refer to the publishing guidelines: https://github.com/EclipseFdn/open-vsx.org/wiki/Publishing-Extensions" + ); + } + // Create the requested namespace var namespace = new Namespace(); namespace.setName(json.getName()); diff --git a/server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java b/server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java index 4b8d59100..ca38f6c1b 100644 --- a/server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java +++ b/server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java @@ -21,6 +21,7 @@ import org.eclipse.openvsx.entities.*; import org.eclipse.openvsx.extension_control.ExtensionControlService; import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.search.SimilarityCheckService; import org.eclipse.openvsx.util.ErrorResultException; import org.eclipse.openvsx.util.ExtensionId; import org.eclipse.openvsx.util.NamingUtil; @@ -50,6 +51,7 @@ public class PublishExtensionVersionHandler { private final UserService users; private final ExtensionValidator validator; private final ExtensionControlService extensionControl; + private final SimilarityCheckService similarityCheckService; public PublishExtensionVersionHandler( PublishExtensionVersionService service, @@ -59,7 +61,8 @@ public PublishExtensionVersionHandler( JobRequestScheduler scheduler, UserService users, ExtensionValidator validator, - ExtensionControlService extensionControl + ExtensionControlService extensionControl, + SimilarityCheckService similarityCheckService ) { this.service = service; this.integrityService = integrityService; @@ -69,6 +72,7 @@ public PublishExtensionVersionHandler( this.users = users; this.validator = validator; this.extensionControl = extensionControl; + this.similarityCheckService = similarityCheckService; } @Transactional(rollbackOn = ErrorResultException.class) @@ -110,8 +114,11 @@ private ExtensionVersion createExtensionVersion(ExtensionProcessor processor, Us var extensionName = processor.getExtensionName(); validateExtensionVersion(processor, namespaceName, extensionName); - + var extVersion = processor.getMetadata(); + var displayName = extVersion.getDisplayName(); + validateExtensionName(namespaceName, extensionName, displayName, user); + extVersion.setTimestamp(timestamp); extVersion.setPublishedWith(token); extVersion.setActive(false); @@ -147,19 +154,54 @@ private ExtensionVersion createExtensionVersion(ExtensionProcessor processor, Us } private void validateExtensionVersion(ExtensionProcessor processor, String namespaceName, String extensionName) { + var version = processor.getVersion(); + var versionIssue = validator.validateExtensionVersion(version); + if (versionIssue.isPresent()) { + throw new ErrorResultException(versionIssue.get().toString()); + } + } + + private void validateExtensionName(String namespaceName, String extensionName, String displayName, UserData user) { var nameIssue = validator.validateExtensionName(extensionName); if (nameIssue.isPresent()) { throw new ErrorResultException(nameIssue.get().toString()); } + if(isMalicious(namespaceName, extensionName)) { throw new ErrorResultException(NamingUtil.toExtensionId(namespaceName, extensionName) + " is a known malicious extension"); } - var version = processor.getVersion(); - var versionIssue = validator.validateExtensionVersion(version); - if (versionIssue.isPresent()) { - throw new ErrorResultException(versionIssue.get().toString()); + validateDistinctName(extensionName, namespaceName, displayName, user); + } + + private void validateDistinctName(String extensionName, String namespaceName, String displayName, UserData user) { + // Use SimilarityCheckService which handles config gates and "exclude owner namespaces" logic + var similarExtensions = similarityCheckService.findSimilarExtensionsForPublishing( + extensionName, + namespaceName, + displayName, + user + ); + + if (similarExtensions.isEmpty()) { + return; } + + var similarExt = similarExtensions.get(0); + var latestVersion = repositories.findLatestVersion(similarExt, null, false, true); + String similarDisplayName = latestVersion != null ? latestVersion.getDisplayName() : null; + + throw new ErrorResultException(String.format( + "Extension '%s.%s' (display name: '%s') is too similar to existing extension '%s.%s' (display name: '%s'). " + + "Please choose a more distinct name to avoid confusion. " + + "Refer to the publishing guidelines: https://github.com/EclipseFdn/open-vsx.org/wiki/Publishing-Extensions", + namespaceName, + extensionName, + displayName, + similarExt.getNamespace().getName(), + similarExt.getName(), + similarDisplayName != null ? similarDisplayName : "" + )); } private void validateMetadata(ExtensionVersion extVersion) { @@ -294,4 +336,4 @@ public void schedulePublicIdJob(FileResource download) { scheduler.enqueue(new VSCodeIdNewExtensionJobRequest(namespace.getName(), extension.getName())); } } -} +} \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionJooqRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionJooqRepository.java index d53db1250..5f1cbf436 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionJooqRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionJooqRepository.java @@ -25,7 +25,9 @@ import java.util.stream.Collectors; import static org.eclipse.openvsx.jooq.Tables.EXTENSION; +import static org.eclipse.openvsx.jooq.Tables.EXTENSION_VERSION; import static org.eclipse.openvsx.jooq.Tables.NAMESPACE; +import static org.eclipse.openvsx.jooq.Tables.NAMESPACE_MEMBERSHIP; @Component public class ExtensionJooqRepository { @@ -270,4 +272,169 @@ public boolean hasExtension(String namespace, String extension) { .and(EXTENSION.NAME.equalIgnoreCase(extension)) ); } + + /** + * Find extensions similar to the given fields using PostgreSQL's Levenshtein distance. + */ + public List findSimilarExtensionsByLevenshtein( + String extensionName, + String namespaceName, + String displayName, + List excludeNamespaces, + double levenshteinThreshold, + boolean verifiedOnly, + int limit + ) { + var query = dsl.selectQuery(); + query.addSelect( + EXTENSION.ID, + EXTENSION.PUBLIC_ID, + EXTENSION.NAME, + EXTENSION.AVERAGE_RATING, + EXTENSION.REVIEW_COUNT, + EXTENSION.DOWNLOAD_COUNT, + EXTENSION.PUBLISHED_DATE, + EXTENSION.LAST_UPDATED_DATE, + NAMESPACE.ID, + NAMESPACE.PUBLIC_ID, + NAMESPACE.NAME, + NAMESPACE.DISPLAY_NAME + ); + + query.addFrom(EXTENSION); + query.addJoin(NAMESPACE, NAMESPACE.ID.eq(EXTENSION.NAMESPACE_ID)); + + query.addConditions(EXTENSION.ACTIVE.eq(true)); + + if (excludeNamespaces != null && !excludeNamespaces.isEmpty()) { + query.addConditions(NAMESPACE.NAME.notIn(excludeNamespaces)); + } + + if (verifiedOnly) { + var hasOwnerSubquery = DSL.selectOne() + .from(NAMESPACE_MEMBERSHIP) + .where(NAMESPACE_MEMBERSHIP.NAMESPACE.eq(NAMESPACE.ID)) + .and(NAMESPACE_MEMBERSHIP.ROLE.eq("owner")); + + query.addConditions(DSL.exists(hasOwnerSubquery)); + } + + var conditions = new ArrayList(); + + if (extensionName != null && !extensionName.isEmpty()) { + var lowerExtensionName = extensionName.toLowerCase(); + + var conditionList = new ArrayList(); + + int inputLen = extensionName.length(); + int minLen = (int) Math.floor(inputLen * (1.0 - levenshteinThreshold)); + int lenMax = (int) Math.ceil(inputLen / (1.0 - levenshteinThreshold)); + conditionList.add(DSL.length(EXTENSION.NAME).between(minLen, lenMax)); + + var maxLen = DSL.greatest( + DSL.val(lowerExtensionName.length()), + DSL.length(EXTENSION.NAME) + ); + var maxDistance = maxLen.mul(levenshteinThreshold); + + var levenshteinDist = DSL.function("levenshtein_less_equal", Integer.class, + DSL.val(lowerExtensionName), + DSL.lower(EXTENSION.NAME), + DSL.val(1), // insertion cost + DSL.val(1), // deletion cost + DSL.val(1), // substitution cost + maxDistance.cast(Integer.class) + ); + + conditionList.add(levenshteinDist.le(maxDistance)); + + conditions.add(DSL.and(conditionList)); + } + + if (namespaceName != null && !namespaceName.isEmpty()) { + var lowerNamespaceName = namespaceName.toLowerCase(); + + var conditionList = new ArrayList(); + + int inputLen = namespaceName.length(); + int minLen = (int) Math.floor(inputLen * (1.0 - levenshteinThreshold)); + int lenMax = (int) Math.ceil(inputLen / (1.0 - levenshteinThreshold)); + conditionList.add(DSL.length(NAMESPACE.NAME).between(minLen, lenMax)); + + var maxLen = DSL.greatest( + DSL.val(lowerNamespaceName.length()), + DSL.length(NAMESPACE.NAME) + ); + var maxDistance = maxLen.mul(levenshteinThreshold); + + var levenshteinDist = DSL.function("levenshtein_less_equal", Integer.class, + DSL.val(lowerNamespaceName), + DSL.lower(NAMESPACE.NAME), + DSL.val(1), // insertion cost + DSL.val(1), // deletion cost + DSL.val(1), // substitution cost + maxDistance.cast(Integer.class) + ); + + conditionList.add(levenshteinDist.le(maxDistance)); + + conditions.add(DSL.and(conditionList)); + } + + if (displayName != null && !displayName.isEmpty()) { + var evLatest = EXTENSION_VERSION.as("ev_latest"); + + var lowerDisplayName = displayName.toLowerCase(); + + int inputLen = displayName.length(); + int minLen = (int) Math.floor(inputLen * (1.0 - levenshteinThreshold)); + int lenMax = (int) Math.ceil(inputLen / (1.0 - levenshteinThreshold)); + + var maxDisplayNameLen = DSL.greatest( + DSL.val(lowerDisplayName.length()), + DSL.length(evLatest.DISPLAY_NAME) + ); + var maxDisplayNameDistance = maxDisplayNameLen.mul(levenshteinThreshold); + + var displayNameLevenshtein = DSL.function("levenshtein_less_equal", Integer.class, + DSL.val(lowerDisplayName), + DSL.lower(evLatest.DISPLAY_NAME), + DSL.val(1), // insertion cost + DSL.val(1), // deletion cost + DSL.val(1), // substitution cost + maxDisplayNameDistance.cast(Integer.class) + ); + + var displayNameSimilaritySubquery = DSL.selectOne() + .from(evLatest) + .where(evLatest.EXTENSION_ID.eq(EXTENSION.ID)) + .and(evLatest.ACTIVE.eq(true)) + .and(evLatest.DISPLAY_NAME.isNotNull()) + .and(evLatest.DISPLAY_NAME.ne("")) + .and(DSL.length(evLatest.DISPLAY_NAME).between(minLen, lenMax)) + .and(displayNameLevenshtein.le(maxDisplayNameDistance)); + + conditions.add(DSL.exists(displayNameSimilaritySubquery)); + } + + if (!conditions.isEmpty()) { + query.addConditions(DSL.or(conditions)); + } else { + return List.of(); + } + + // Order by best match first (lowest Levenshtein distance) + if (extensionName != null && !extensionName.isEmpty()) { + var lowerExtensionName = extensionName.toLowerCase(); + var levenshteinDist = DSL.function("levenshtein", Integer.class, + DSL.val(lowerExtensionName), + DSL.lower(EXTENSION.NAME) + ); + query.addOrderBy(levenshteinDist.asc()); + } + + query.addLimit(limit); + + return query.fetch().map(this::toExtension); + } } diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/NamespaceJooqRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/NamespaceJooqRepository.java index 088ee10a1..e3149a893 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/NamespaceJooqRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/NamespaceJooqRepository.java @@ -9,15 +9,18 @@ * ****************************************************************************** */ package org.eclipse.openvsx.repositories; +import org.eclipse.openvsx.entities.Namespace; import org.jooq.DSLContext; import org.jooq.Row2; import org.jooq.impl.DSL; import org.springframework.stereotype.Component; +import java.util.List; import java.util.Map; import java.util.stream.Collectors; import static org.eclipse.openvsx.jooq.Tables.NAMESPACE; +import static org.eclipse.openvsx.jooq.Tables.NAMESPACE_MEMBERSHIP; @Component public class NamespaceJooqRepository { @@ -64,4 +67,79 @@ public String findNameByNameIgnoreCase(String name) { public boolean exists(String name) { return dsl.fetchExists(dsl.selectOne().from(NAMESPACE).where(NAMESPACE.NAME.equalIgnoreCase(name))); } + + /** + * Find namespaces with names similar to the given name using PostgreSQL's Levenshtein distance. + * This checks if any existing namespace names are too similar to the proposed name. + */ + public List findSimilarNamespacesByLevenshtein( + String namespaceName, + List excludeNamespaces, + double levenshteinThreshold, + boolean verifiedOnly, + int limit + ) { + var query = dsl.selectQuery(); + + query.addSelect( + NAMESPACE.ID, + NAMESPACE.PUBLIC_ID, + NAMESPACE.NAME, + NAMESPACE.DISPLAY_NAME + ); + + query.addFrom(NAMESPACE); + + if (excludeNamespaces != null && !excludeNamespaces.isEmpty()) { + query.addConditions(NAMESPACE.NAME.notIn(excludeNamespaces)); + } + + if (verifiedOnly) { + var hasOwnerSubquery = DSL.selectOne() + .from(NAMESPACE_MEMBERSHIP) + .where(NAMESPACE_MEMBERSHIP.NAMESPACE.eq(NAMESPACE.ID)) + .and(NAMESPACE_MEMBERSHIP.ROLE.eq("owner")); + + query.addConditions(DSL.exists(hasOwnerSubquery)); + } + + var lowerNamespaceName = namespaceName.toLowerCase(); + + int inputLen = namespaceName.length(); + int minLen = (int) Math.floor(inputLen * (1.0 - levenshteinThreshold)); + int maxLen = (int) Math.ceil(inputLen / (1.0 - levenshteinThreshold)); + + query.addConditions(DSL.length(NAMESPACE.NAME).between(minLen, maxLen)); + + var maxLength = DSL.greatest( + DSL.val(lowerNamespaceName.length()), + DSL.length(NAMESPACE.NAME) + ); + var maxDistance = maxLength.mul(levenshteinThreshold); + + var levenshteinDist = DSL.function("levenshtein_less_equal", Integer.class, + DSL.val(lowerNamespaceName), + DSL.lower(NAMESPACE.NAME), + DSL.val(1), // insertion cost + DSL.val(1), // deletion cost + DSL.val(1), // substitution cost + maxDistance.cast(Integer.class) + ); + + query.addConditions(levenshteinDist.le(maxDistance)); + + // Order by best match first (lowest Levenshtein distance) + query.addOrderBy(levenshteinDist.asc()); + query.addLimit(limit); + + // Map results to Namespace entities + return query.fetch().map(record -> { + var namespace = new Namespace(); + namespace.setId(record.get(NAMESPACE.ID)); + namespace.setPublicId(record.get(NAMESPACE.PUBLIC_ID)); + namespace.setName(record.get(NAMESPACE.NAME)); + namespace.setDisplayName(record.get(NAMESPACE.DISPLAY_NAME)); + return namespace; + }); + } } diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java index d909d1823..2952e3392 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -659,4 +659,40 @@ public List findRemoveFileResourceTypeResourceMigrationItems(int public boolean isDeleteAllVersions(String namespaceName, String extensionName, List targetVersions, UserData user) { return extensionVersionJooqRepo.isDeleteAllVersions(namespaceName, extensionName, targetVersions, user); } + + public List findSimilarExtensionsByLevenshtein( + String extensionName, + String namespaceName, + String displayName, + List excludeNamespaces, + double levenshteinThreshold, + boolean verifiedOnly, + int limit + ) { + return extensionJooqRepo.findSimilarExtensionsByLevenshtein( + extensionName, + namespaceName, + displayName, + excludeNamespaces, + levenshteinThreshold, + verifiedOnly, + limit + ); + } + + public List findSimilarNamespacesByLevenshtein( + String namespaceName, + List excludeNamespaces, + double levenshteinThreshold, + boolean verifiedOnly, + int limit + ) { + return namespaceJooqRepo.findSimilarNamespacesByLevenshtein( + namespaceName, + excludeNamespaces, + levenshteinThreshold, + verifiedOnly, + limit + ); + } } \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/search/SimilarityCheckService.java b/server/src/main/java/org/eclipse/openvsx/search/SimilarityCheckService.java new file mode 100644 index 000000000..8f4d2c731 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/search/SimilarityCheckService.java @@ -0,0 +1,119 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.search; + +import org.eclipse.openvsx.entities.Extension; +import org.eclipse.openvsx.entities.Namespace; +import org.eclipse.openvsx.entities.NamespaceMembership; +import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * Central entry point for enforcing similarity rules with configuration-based policy. + * + * This service reads {@link SimilarityConfig} and applies all policy decisions. + */ +@Service +public class SimilarityCheckService { + + private static final int LIMIT = 10; + + private final SimilarityConfig config; + private final SimilarityService similarityService; + private final RepositoryService repositories; + + public SimilarityCheckService( + SimilarityConfig config, + SimilarityService similarityService, + RepositoryService repositories + ) { + this.config = config; + this.similarityService = similarityService; + this.repositories = repositories; + } + + /** + * Enforce configured similarity rules for publishing an extension. + */ + public List findSimilarExtensionsForPublishing( + String extensionName, + String namespaceName, + String displayName, + UserData publishingUser + ) { + if (!config.isEnabled()) { + return List.of(); + } + + if (config.isNewExtensionsOnly() && namespaceName != null && extensionName != null) { + if (repositories.countVersions(namespaceName, extensionName) > 0) { + return List.of(); + } + } + + if (config.isSkipVerifiedPublishers() && namespaceName != null) { + var namespace = repositories.findNamespace(namespaceName); + if (namespace != null && repositories.hasMemberships(namespace, NamespaceMembership.ROLE_OWNER)) { + return List.of(); + } + } + + List excludeNamespaces = config.isExcludeOwnerNamespaces() + ? repositories.findMemberships(publishingUser) + .stream() + .filter(membership -> NamespaceMembership.ROLE_OWNER.equals(membership.getRole())) + .map(membership -> membership.getNamespace().getName()) + .toList() + : List.of(); + + return similarityService.findSimilarExtensions( + extensionName, + namespaceName, + displayName, + excludeNamespaces, + config.getLevenshteinThreshold(), + config.isCheckAgainstVerifiedOnly(), + LIMIT + ); + } + + /** + * Enforce configured similarity rules for namespace creation. + */ + public List findSimilarNamespacesForCreation(String namespaceName, UserData publishingUser) { + if (!config.isEnabled()) { + return List.of(); + } + + List excludeNamespaces = config.isExcludeOwnerNamespaces() + ? repositories.findMemberships(publishingUser) + .stream() + .filter(membership -> NamespaceMembership.ROLE_OWNER.equals(membership.getRole())) + .map(membership -> membership.getNamespace().getName()) + .toList() + : List.of(); + + return similarityService.findSimilarNamespaces( + namespaceName, + excludeNamespaces, + config.getLevenshteinThreshold(), + config.isCheckAgainstVerifiedOnly(), + LIMIT + ); + } +} + + diff --git a/server/src/main/java/org/eclipse/openvsx/search/SimilarityConfig.java b/server/src/main/java/org/eclipse/openvsx/search/SimilarityConfig.java new file mode 100644 index 000000000..14b848f20 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/search/SimilarityConfig.java @@ -0,0 +1,126 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.search; + +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration for extension similarity checking. + * + * Configuration example: + * ovsx: + * similarity: + * enabled: false # Enables/disables similarity checks during publishing. + * new-extensions-only: false # If true, only check the very first upload (no prior versions). + * levenshtein-threshold: 0.15 # Valid range: 0.0 - 0.3. Smaller values are stricter. + * skip-verified-publishers: false # If true, skip checks for verified publishers. + * check-against-verified-only: false # If true, compare only against verified publishers' extensions. + * exclude-owner-namespaces: true # If true, exclude namespaces where the publisher is an owner. + */ +@Configuration +public class SimilarityConfig { + /** + * If enabled, run similarity checks during extension publishing. + * + * Property: {@code ovsx.similarity.enabled} + * Default: {@code false} + */ + @Value("${ovsx.similarity.enabled:false}") + private boolean enabled; + + /** + * If enabled, only run similarity checks for the very first upload of an extension. + * This means updates to an already existing extension (new versions) are not blocked by similarity. + * + * Property: {@code ovsx.similarity.new-extensions-only} + * Default: {@code false} + */ + @Value("${ovsx.similarity.new-extensions-only:false}") + private boolean newExtensionsOnly; + + /** + * Levenshtein threshold used to decide whether two extension identifiers are "too similar". + * The check compares the edit distance against a fraction of the identifier length. + * Smaller values are stricter. For example {@code 0.15} requires at least ~15% difference. + * + * Property: {@code ovsx.similarity.levenshtein-threshold} + * Default: {@code 0.15}
+ * Valid range: {@code 0.0} - {@code 0.3} (validated at startup) + */ + @Value("${ovsx.similarity.levenshtein-threshold:0.15}") + private double levenshteinThreshold; + + /** + * If enabled, do not run similarity checks for verified publishers. + * This reduces friction for trusted publishers while keeping checks for unverified ones. + * + * Property: {@code ovsx.similarity.skip-verified-publishers} + * Default: {@code false} + */ + @Value("${ovsx.similarity.skip-verified-publishers:false}") + private boolean skipVerifiedPublishers; + + /** + * If enabled, compare new extensions only against extensions from verified publishers. + * This reduces noise by focusing on protecting well-known publishers. + * + * Property: {@code ovsx.similarity.check-against-verified-only} + * Default: {@code false} + */ + @Value("${ovsx.similarity.check-against-verified-only:false}") + private boolean checkAgainstVerifiedOnly; + + /** + * If enabled, exclude namespaces where the publishing user is an owner from similarity checks. + * This prevents false positives when a user legitimately controls multiple namespaces. + * + * Property: {@code ovsx.similarity.exclude-owner-namespaces} + * Default: {@code true} + */ + @Value("${ovsx.similarity.exclude-owner-namespaces:true}") + private boolean excludeOwnerNamespaces; + + public boolean isEnabled() { + return enabled; + } + + public boolean isNewExtensionsOnly() { + return newExtensionsOnly; + } + + public double getLevenshteinThreshold() { + return levenshteinThreshold; + } + + public boolean isSkipVerifiedPublishers() { + return skipVerifiedPublishers; + } + + public boolean isCheckAgainstVerifiedOnly() { + return checkAgainstVerifiedOnly; + } + + public boolean isExcludeOwnerNamespaces() { + return excludeOwnerNamespaces; + } + + @PostConstruct + public void validate() { + if (levenshteinThreshold < 0.0 || levenshteinThreshold > 0.3) { + throw new IllegalArgumentException( + "ovsx.similarity.levenshtein-threshold must be between 0.0 and 0.3, got: " + levenshteinThreshold); + } + } +} \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/search/SimilarityService.java b/server/src/main/java/org/eclipse/openvsx/search/SimilarityService.java new file mode 100644 index 000000000..58cd3f069 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/search/SimilarityService.java @@ -0,0 +1,107 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.search; + +import jakarta.validation.constraints.NotNull; +import org.eclipse.openvsx.entities.Extension; +import org.eclipse.openvsx.entities.Namespace; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.List; +import javax.annotation.Nullable; + +@Service +public class SimilarityService { + private static final Logger logger = LoggerFactory.getLogger(SimilarityService.class); + + private final RepositoryService repositories; + + public SimilarityService(RepositoryService repositories) { + this.repositories = repositories; + } + + /** + * Find extensions similar to the given fields using Levenshtein distance. + */ + public List findSimilarExtensions( + @Nullable String extensionName, + @Nullable String namespaceName, + @Nullable String displayName, + @NotNull List excludeNamespaces, + double threshold, + boolean verifiedOnly, + int limit) { + + if (extensionName == null && namespaceName == null && displayName == null) { + return List.of(); + } + + try { + return repositories.findSimilarExtensionsByLevenshtein( + extensionName, + namespaceName, + displayName, + excludeNamespaces, + threshold, + verifiedOnly, + limit + ); + } catch (Exception e) { + logger.error("Similarity check failed for extension='{}', namespace='{}', displayName='{}': {}", + extensionName, namespaceName, displayName, e.getMessage(), e); + + throw new RuntimeException( + "Unable to verify extension name uniqueness due to system error. " + + "Please try again later or contact support if the problem persists." + ); + } + } + + + /** + * Find namespaces similar to the given namespace name using Levenshtein distance. + */ + public List findSimilarNamespaces( + @NotNull String namespaceName, + @NotNull List excludeNamespaces, + double threshold, + boolean verifiedOnly, + int limit) { + + if (namespaceName.isEmpty()) { + return List.of(); + } + + try { + return repositories.findSimilarNamespacesByLevenshtein( + namespaceName, + excludeNamespaces, + threshold, + verifiedOnly, + limit + ); + } catch (Exception e) { + logger.error("Similarity check failed for namespace='{}': {}", + namespaceName, e.getMessage(), e); + + throw new RuntimeException( + "Unable to verify namespace name uniqueness due to system error. " + + "Please try again later or contact support if the problem persists." + ); + } + } + +} \ No newline at end of file diff --git a/server/src/main/resources/db/migration/V1_58__Enable_Fuzzystrmatch_Extension.sql b/server/src/main/resources/db/migration/V1_58__Enable_Fuzzystrmatch_Extension.sql new file mode 100644 index 000000000..7906b0a04 --- /dev/null +++ b/server/src/main/resources/db/migration/V1_58__Enable_Fuzzystrmatch_Extension.sql @@ -0,0 +1,12 @@ +-- Enable the fuzzystrmatch extension for Levenshtein distance calculations +-- This extension provides fuzzy string matching functions including levenshtein() +-- which is used by the similarity service for name squatting detection +-- +-- The levenshtein function calculates the edit distance between two strings +-- This allows us to detect extensions with names that are too similar to existing ones +-- +-- Reference: https://www.postgresql.org/docs/current/fuzzystrmatch.html + +CREATE EXTENSION IF NOT EXISTS fuzzystrmatch; + + diff --git a/server/src/test/java/org/eclipse/openvsx/LocalRegistryServiceTest.java b/server/src/test/java/org/eclipse/openvsx/LocalRegistryServiceTest.java new file mode 100644 index 000000000..76c4a505f --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/LocalRegistryServiceTest.java @@ -0,0 +1,191 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx; + +import jakarta.persistence.EntityManager; +import org.eclipse.openvsx.cache.CacheService; +import org.eclipse.openvsx.eclipse.EclipseService; +import org.eclipse.openvsx.entities.Namespace; +import org.eclipse.openvsx.entities.NamespaceMembership; +import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.json.NamespaceJson; +import org.eclipse.openvsx.publish.ExtensionVersionIntegrityService; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.search.SearchUtilService; +import org.eclipse.openvsx.search.SimilarityCheckService; +import org.eclipse.openvsx.storage.StorageUtilService; +import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.VersionService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.util.Streamable; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class LocalRegistryServiceTest { + + @Mock + EntityManager entityManager; + + @Mock + RepositoryService repositories; + + @Mock + ExtensionService extensions; + + @Mock + VersionService versions; + + @Mock + UserService users; + + @Mock + SearchUtilService searchUtilService; + + @Mock + ExtensionValidator validator; + + @Mock + StorageUtilService storageUtilService; + + @Mock + EclipseService eclipse; + + @Mock + CacheService cacheService; + + @Mock + ExtensionVersionIntegrityService integrityService; + + @Mock + SimilarityCheckService similarityCheckService; + + private LocalRegistryService registryService; + + @BeforeEach + void setUp() { + registryService = new LocalRegistryService( + entityManager, + repositories, + extensions, + versions, + users, + searchUtilService, + validator, + storageUtilService, + eclipse, + cacheService, + integrityService, + similarityCheckService + ); + + doNothing().when(eclipse).checkPublisherAgreement(any()); + } + + @Test + void shouldRejectNamespaceWhenSimilarNameExists() { + // Build request with a name that collides with an existing namespace. + var json = new NamespaceJson(); + json.setName("new-space"); + var user = new UserData(); + + when(validator.validateNamespace("new-space")).thenReturn(Optional.empty()); + when(repositories.findNamespaceName("new-space")).thenReturn(null); + when(similarityCheckService.findSimilarNamespacesForCreation("new-space", user)) + .thenReturn(List.of(buildNamespace("new-space-1"))); + + assertThatThrownBy(() -> registryService.createNamespace(json, user)) + .isInstanceOf(ErrorResultException.class) + .hasMessageContaining("too similar to existing namespace"); + + verify(entityManager, never()).persist(any(Namespace.class)); + } + + @Test + void shouldRejectExistingNamespaceBeforeSimilarityCheck() { + // If the namespace already exists, we should fail fast and avoid extra work. + var json = new NamespaceJson(); + json.setName("duplicate"); + var user = new UserData(); + + when(validator.validateNamespace("duplicate")).thenReturn(Optional.empty()); + when(repositories.findNamespaceName("duplicate")).thenReturn("duplicate"); + + assertThatThrownBy(() -> registryService.createNamespace(json, user)) + .isInstanceOf(ErrorResultException.class) + .hasMessageContaining("Namespace already exists: duplicate"); + + // No persistence and no similarity checks should occur when we bail out early. + verify(entityManager, never()).persist(any(Namespace.class)); + verify(similarityCheckService, never()).findSimilarNamespacesForCreation(any(), any()); + } + + @Test + void shouldCreateNamespaceAndAssignContributorRole() { + // Happy path: namespace is new and not similar, so we persist both entities. + var json = new NamespaceJson(); + json.setName("clean-ns"); + var user = new UserData(); + + when(validator.validateNamespace("clean-ns")).thenReturn(Optional.empty()); + when(repositories.findNamespaceName("clean-ns")).thenReturn(null); + when(similarityCheckService.findSimilarNamespacesForCreation("clean-ns", user)).thenReturn(List.of()); + + registryService.createNamespace(json, user); + + // Capture persisted entities to verify they are wired as expected. + var namespaceCaptor = ArgumentCaptor.forClass(Namespace.class); + var membershipCaptor = ArgumentCaptor.forClass(NamespaceMembership.class); + + verify(entityManager).persist(namespaceCaptor.capture()); + verify(entityManager).persist(membershipCaptor.capture()); + + var persistedNamespace = namespaceCaptor.getValue(); + var persistedMembership = membershipCaptor.getValue(); + + assertThat(persistedNamespace.getName()).isEqualTo("clean-ns"); + assertThat(persistedMembership.getNamespace()).isSameAs(persistedNamespace); + assertThat(persistedMembership.getUser()).isSameAs(user); + assertThat(persistedMembership.getRole()).isEqualTo(NamespaceMembership.ROLE_CONTRIBUTOR); + } + + private Namespace buildNamespace(String name) { + var namespace = new Namespace(); + namespace.setName(name); + return namespace; + } + + private NamespaceMembership buildMembership(UserData user, String namespaceName) { + var namespace = new Namespace(); + namespace.setName(namespaceName); + var membership = new NamespaceMembership(); + membership.setNamespace(namespace); + membership.setUser(user); + return membership; + } +} + diff --git a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java index 19d565c16..230583327 100644 --- a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java @@ -1402,7 +1402,10 @@ void testPostQueryNamespaceUuid() throws Exception { @Test void testCreateNamespace() throws Exception { - mockAccessToken(); + var token = mockAccessToken(); + // Mock findMemberships(user) for similarity check during namespace creation + Mockito.when(repositories.findMemberships(token.getUser())) + .thenReturn(Streamable.empty()); mockMvc.perform(post("/api/-/namespace/create?token={token}", "my_token") .contentType(MediaType.APPLICATION_JSON) .content(namespaceJson(n -> { n.setName("foobar"); }))) @@ -2364,6 +2367,9 @@ private void mockForPublish(String mode) { .thenReturn(true); Mockito.when(repositories.isVerified(namespace, token.getUser())) .thenReturn(true); + // Mock findMemberships(user) for similarity check + Mockito.when(repositories.findMemberships(token.getUser())) + .thenReturn(Streamable.of(ownerMem)); } else if (mode.equals("contributor") || mode.equals("sole-contributor") || mode.equals("existing")) { Mockito.when(repositories.canPublishInNamespace(token.getUser(), namespace)) .thenReturn(true); @@ -2380,11 +2386,25 @@ private void mockForPublish(String mode) { .thenReturn(Streamable.of(ownerMem)); Mockito.when(repositories.isVerified(namespace, token.getUser())) .thenReturn(true); + // Mock findMemberships(user) for similarity check - user is a contributor + var contributorMem = new NamespaceMembership(); + contributorMem.setUser(token.getUser()); + contributorMem.setNamespace(namespace); + contributorMem.setRole(NamespaceMembership.ROLE_CONTRIBUTOR); + Mockito.when(repositories.findMemberships(token.getUser())) + .thenReturn(Streamable.of(contributorMem)); } else { Mockito.when(repositories.findMemberships(namespace, NamespaceMembership.ROLE_OWNER)) .thenReturn(Streamable.empty()); Mockito.when(repositories.isVerified(namespace, token.getUser())) .thenReturn(false); + // Mock findMemberships(user) for similarity check - user might be sole contributor + var contributorMem = new NamespaceMembership(); + contributorMem.setUser(token.getUser()); + contributorMem.setNamespace(namespace); + contributorMem.setRole(NamespaceMembership.ROLE_CONTRIBUTOR); + Mockito.when(repositories.findMemberships(token.getUser())) + .thenReturn(Streamable.of(contributorMem)); } } else if (mode.equals("privileged") || mode.equals("unrelated")) { var otherUser = new UserData(); @@ -2399,12 +2419,22 @@ private void mockForPublish(String mode) { .thenReturn(true); if (mode.equals("privileged")) { token.getUser().setRole(UserData.ROLE_PRIVILEGED); + // Mock findMemberships(user) for similarity check - privileged user might have memberships + Mockito.when(repositories.findMemberships(token.getUser())) + .thenReturn(Streamable.empty()); + } else { + // Mock findMemberships(user) for similarity check - unrelated user has no memberships + Mockito.when(repositories.findMemberships(token.getUser())) + .thenReturn(Streamable.empty()); } } else { Mockito.when(repositories.findMemberships(namespace, NamespaceMembership.ROLE_OWNER)) .thenReturn(Streamable.empty()); Mockito.when(repositories.hasMemberships(namespace, NamespaceMembership.ROLE_OWNER)) .thenReturn(false); + // Mock findMemberships(user) for similarity check - default to empty + Mockito.when(repositories.findMemberships(token.getUser())) + .thenReturn(Streamable.empty()); } Mockito.when(entityManager.merge(any(Extension.class))) @@ -2546,7 +2576,8 @@ LocalRegistryService localRegistryService( StorageUtilService storageUtil, EclipseService eclipse, CacheService cache, - ExtensionVersionIntegrityService integrityService + ExtensionVersionIntegrityService integrityService, + SimilarityService similarityService ) { return new LocalRegistryService( entityManager, @@ -2559,7 +2590,8 @@ LocalRegistryService localRegistryService( storageUtil, eclipse, cache, - integrityService + integrityService, + similarityCheckService(similarityConfig(), similarityService(repositories), repositories) ); } @@ -2627,6 +2659,25 @@ LatestExtensionVersionCacheKeyGenerator latestExtensionVersionCacheKeyGenerator( return new LatestExtensionVersionCacheKeyGenerator(); } + @Bean + SimilarityConfig similarityConfig() { + return new SimilarityConfig(); + } + + @Bean + SimilarityService similarityService(RepositoryService repositories) { + return new SimilarityService(repositories); + } + + @Bean + SimilarityCheckService similarityCheckService( + SimilarityConfig config, + SimilarityService similarityService, + RepositoryService repositories + ) { + return new SimilarityCheckService(config, similarityService, repositories); + } + @Bean PublishExtensionVersionHandler publishExtensionVersionHandler( PublishExtensionVersionService service, @@ -2636,7 +2687,8 @@ PublishExtensionVersionHandler publishExtensionVersionHandler( JobRequestScheduler scheduler, UserService users, ExtensionValidator validator, - ExtensionControlService extensionControl + ExtensionControlService extensionControl, + SimilarityCheckService similarityCheckService ) { return new PublishExtensionVersionHandler( service, @@ -2646,7 +2698,8 @@ PublishExtensionVersionHandler publishExtensionVersionHandler( scheduler, users, validator, - extensionControl + extensionControl, + similarityCheckService ); } } diff --git a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java index faecc2704..75ff8a7a3 100644 --- a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java @@ -23,6 +23,9 @@ import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.search.SearchUtilService; +import org.eclipse.openvsx.search.SimilarityCheckService; +import org.eclipse.openvsx.search.SimilarityConfig; +import org.eclipse.openvsx.search.SimilarityService; import org.eclipse.openvsx.security.OAuth2AttributesConfig; import org.eclipse.openvsx.security.OAuth2UserServices; import org.eclipse.openvsx.security.SecurityConfig; @@ -817,7 +820,8 @@ LocalRegistryService localRegistryService( StorageUtilService storageUtil, EclipseService eclipse, CacheService cache, - ExtensionVersionIntegrityService integrityService + ExtensionVersionIntegrityService integrityService, + SimilarityService similarityService ) { return new LocalRegistryService( entityManager, @@ -830,10 +834,30 @@ LocalRegistryService localRegistryService( storageUtil, eclipse, cache, - integrityService + integrityService, + similarityCheckService(similarityConfig(), similarityService(repositories), repositories) ); } + @Bean + SimilarityConfig similarityConfig() { + return new SimilarityConfig(); + } + + @Bean + SimilarityService similarityService(RepositoryService repositories) { + return new SimilarityService(repositories); + } + + @Bean + SimilarityCheckService similarityCheckService( + SimilarityConfig config, + SimilarityService similarityService, + RepositoryService repositories + ) { + return new SimilarityCheckService(config, similarityService, repositories); + } + @Bean ExtensionService extensionService( EntityManager entityManager, diff --git a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java index f6bb5eee4..d4e4d9d6a 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java @@ -26,6 +26,9 @@ import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.search.SearchUtilService; +import org.eclipse.openvsx.search.SimilarityCheckService; +import org.eclipse.openvsx.search.SimilarityConfig; +import org.eclipse.openvsx.search.SimilarityService; import org.eclipse.openvsx.security.OAuth2AttributesConfig; import org.eclipse.openvsx.security.OAuth2UserServices; import org.eclipse.openvsx.security.SecurityConfig; @@ -1407,7 +1410,8 @@ LocalRegistryService localRegistryService( EclipseService eclipse, CacheService cache, FileCacheDurationConfig fileCacheDurationConfig, - ExtensionVersionIntegrityService integrityService + ExtensionVersionIntegrityService integrityService, + SimilarityService similarityService ) { return new LocalRegistryService( entityManager, @@ -1420,10 +1424,30 @@ LocalRegistryService localRegistryService( storageUtil, eclipse, cache, - integrityService + integrityService, + similarityCheckService(similarityConfig(), similarityService(repositories), repositories) ); } + @Bean + SimilarityConfig similarityConfig() { + return new SimilarityConfig(); + } + + @Bean + SimilarityService similarityService(RepositoryService repositories) { + return new SimilarityService(repositories); + } + + @Bean + SimilarityCheckService similarityCheckService( + SimilarityConfig config, + SimilarityService similarityService, + RepositoryService repositories + ) { + return new SimilarityCheckService(config, similarityService, repositories); + } + @Bean ExtensionService extensionService( EntityManager entityManager, diff --git a/server/src/test/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandlerTest.java b/server/src/test/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandlerTest.java new file mode 100644 index 000000000..ef1f1036b --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandlerTest.java @@ -0,0 +1,259 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.publish; + +import jakarta.persistence.EntityManager; +import org.eclipse.openvsx.ExtensionProcessor; +import org.eclipse.openvsx.ExtensionValidator; +import org.eclipse.openvsx.UserService; +import org.eclipse.openvsx.entities.Extension; +import org.eclipse.openvsx.entities.ExtensionVersion; +import org.eclipse.openvsx.entities.Namespace; +import org.eclipse.openvsx.entities.NamespaceMembership; +import org.eclipse.openvsx.entities.PersonalAccessToken; +import org.eclipse.openvsx.extension_control.ExtensionControlService; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.search.SimilarityCheckService; +import org.eclipse.openvsx.util.ErrorResultException; +import org.jobrunr.scheduling.JobRequestScheduler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.util.Streamable; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PublishExtensionVersionHandlerTest { + + @Mock + PublishExtensionVersionService publishService; + + @Mock + ExtensionVersionIntegrityService integrityService; + + @Mock + EntityManager entityManager; + + @Mock + RepositoryService repositories; + + @Mock + JobRequestScheduler scheduler; + + @Mock + UserService users; + + @Mock + ExtensionValidator validator; + + @Mock + ExtensionControlService extensionControl; + + @Mock + SimilarityCheckService similarityCheckService; + + private PublishExtensionVersionHandler handler; + + @BeforeEach + void setUp() throws Exception { + // Keep defaults permissive so tests focus on similarity behaviour. + handler = new PublishExtensionVersionHandler( + publishService, + integrityService, + entityManager, + repositories, + scheduler, + users, + validator, + extensionControl, + similarityCheckService + ); + when(extensionControl.getMaliciousExtensionIds()).thenReturn(Collections.emptyList()); + } + + @Test + void shouldFailPublishingWhenSimilarExtensionAlreadyExists() { + // Build minimal processor metadata. + var processor = org.mockito.Mockito.mock(ExtensionProcessor.class); + when(processor.getNamespace()).thenReturn("publisher"); + when(processor.getExtensionName()).thenReturn("demo"); + when(processor.getVersion()).thenReturn("1.0.0"); + + var metadata = new ExtensionVersion(); + metadata.setDisplayName("Demo Extension"); + metadata.setVersion("1.0.0"); + metadata.setTargetPlatform("any"); + when(processor.getMetadata()).thenReturn(metadata); + + var user = new org.eclipse.openvsx.entities.UserData(); + var token = new PersonalAccessToken(); + token.setUser(user); + + var namespace = new Namespace(); + namespace.setName("publisher"); + when(repositories.findNamespace("publisher")).thenReturn(namespace); + when(users.hasPublishPermission(user, namespace)).thenReturn(true); + when(validator.validateExtensionVersion("1.0.0")).thenReturn(Optional.empty()); + when(validator.validateExtensionName("demo")).thenReturn(Optional.empty()); + + var similarExtension = new Extension(); + similarExtension.setNamespace(buildNamespace("other")); + similarExtension.setName("demo-other"); + when(similarityCheckService.findSimilarExtensionsForPublishing("demo", "publisher", "Demo Extension", user)) + .thenReturn(List.of(similarExtension)); + + var similarLatest = new ExtensionVersion(); + similarLatest.setDisplayName("Existing Demo"); + when(repositories.findLatestVersion(similarExtension, null, false, true)).thenReturn(similarLatest); + + assertThatThrownBy(() -> handler.createExtensionVersion(processor, token, LocalDateTime.now(), true)) + .isInstanceOf(ErrorResultException.class) + .hasMessageContaining("too similar to existing extension"); + + // Persist should never happen because we bail out early on similarity. + verify(entityManager, never()).persist(metadata); + verify(similarityCheckService).findSimilarExtensionsForPublishing("demo", "publisher", "Demo Extension", user); + } + + @Test + void shouldExcludeOwnedNamespacesFromSimilarityCheck() { + // Ensure owner namespaces are excluded when configured. + var processor = org.mockito.Mockito.mock(ExtensionProcessor.class); + when(processor.getNamespace()).thenReturn("publisher"); + when(processor.getExtensionName()).thenReturn("demo"); + when(processor.getVersion()).thenReturn("1.0.1"); + when(processor.getExtensionDependencies()).thenReturn(List.of()); + when(processor.getBundledExtensions()).thenReturn(List.of()); + + var metadata = new ExtensionVersion(); + metadata.setDisplayName("Demo Next"); + metadata.setVersion("1.0.1"); + metadata.setTargetPlatform("any"); + when(processor.getMetadata()).thenReturn(metadata); + + var namespace = buildNamespace("publisher"); + var ownedNamespace = buildNamespace("owned-ns"); + var user = new org.eclipse.openvsx.entities.UserData(); + var token = new PersonalAccessToken(); + token.setUser(user); + + when(repositories.findNamespace("publisher")).thenReturn(namespace); + when(users.hasPublishPermission(user, namespace)).thenReturn(true); + when(validator.validateExtensionVersion("1.0.1")).thenReturn(Optional.empty()); + when(validator.validateExtensionName("demo")).thenReturn(Optional.empty()); + when(validator.validateMetadata(metadata)).thenReturn(List.of()); + when(similarityCheckService.findSimilarExtensionsForPublishing("demo", "publisher", "Demo Next", user)) + .thenReturn(List.of()); + when(repositories.findExtension("demo", namespace)).thenReturn(null); + + handler.createExtensionVersion(processor, token, LocalDateTime.now(), false); + + verify(similarityCheckService).findSimilarExtensionsForPublishing("demo", "publisher", "Demo Next", user); + } + + @Test + void shouldCreateExtensionWhenSimilarityFindsNoConflicts() { + // Happy path: similarity passes and entities get persisted and linked. + var processor = org.mockito.Mockito.mock(ExtensionProcessor.class); + when(processor.getNamespace()).thenReturn("publisher"); + when(processor.getExtensionName()).thenReturn("demo"); + when(processor.getVersion()).thenReturn("2.0.0"); + when(processor.getExtensionDependencies()).thenReturn(List.of()); + when(processor.getBundledExtensions()).thenReturn(List.of()); + + var metadata = new ExtensionVersion(); + metadata.setDisplayName("Demo OK"); + metadata.setVersion("2.0.0"); + metadata.setTargetPlatform("any"); + when(processor.getMetadata()).thenReturn(metadata); + + var namespace = buildNamespace("publisher"); + var user = new org.eclipse.openvsx.entities.UserData(); + var token = new PersonalAccessToken(); + token.setUser(user); + + when(repositories.findNamespace("publisher")).thenReturn(namespace); + when(users.hasPublishPermission(user, namespace)).thenReturn(true); + when(validator.validateExtensionVersion("2.0.0")).thenReturn(Optional.empty()); + when(validator.validateExtensionName("demo")).thenReturn(Optional.empty()); + when(validator.validateMetadata(metadata)).thenReturn(List.of()); + when(similarityCheckService.findSimilarExtensionsForPublishing("demo", "publisher", "Demo OK", user)) + .thenReturn(List.of()); + when(repositories.findExtension("demo", namespace)).thenReturn(null); + + var capturedNamespace = ArgumentCaptor.forClass(Extension.class); + + var result = handler.createExtensionVersion(processor, token, LocalDateTime.now(), false); + + verify(entityManager).persist(capturedNamespace.capture()); + verify(entityManager).persist(metadata); + assertThat(result).isSameAs(metadata); + assertThat(result.getPublishedWith()).isEqualTo(token); + assertThat(result.getExtension()).isSameAs(capturedNamespace.getValue()); + assertThat(result.getExtension().getNamespace()).isSameAs(namespace); + } + + @Test + void shouldCheckSimilarityForAllExtensions() { + // All extensions should be checked for similarity. + var processor = org.mockito.Mockito.mock(ExtensionProcessor.class); + when(processor.getNamespace()).thenReturn("pub"); + when(processor.getExtensionName()).thenReturn("demo"); + when(processor.getVersion()).thenReturn("3.0.0"); + when(processor.getMetadata()).thenReturn(new ExtensionVersion()); + + var user = new org.eclipse.openvsx.entities.UserData(); + var token = new PersonalAccessToken(); + token.setUser(user); + var namespace = buildNamespace("pub"); + + when(repositories.findNamespace("pub")).thenReturn(namespace); + when(users.hasPublishPermission(user, namespace)).thenReturn(true); + when(validator.validateExtensionVersion("3.0.0")).thenReturn(Optional.empty()); + when(validator.validateExtensionName("demo")).thenReturn(Optional.empty()); + when(validator.validateMetadata(processor.getMetadata())).thenReturn(List.of()); + when(similarityCheckService.findSimilarExtensionsForPublishing("demo", "pub", null, user)).thenReturn(List.of()); + when(repositories.findExtension("demo", namespace)).thenReturn(null); + + handler.createExtensionVersion(processor, token, LocalDateTime.now(), false); + + verify(similarityCheckService).findSimilarExtensionsForPublishing("demo", "pub", null, user); + } + + private NamespaceMembership buildOwnerMembership(Namespace namespace) { + var membership = new NamespaceMembership(); + membership.setNamespace(namespace); + membership.setRole(NamespaceMembership.ROLE_OWNER); + return membership; + } + + private Namespace buildNamespace(String name) { + var namespace = new Namespace(); + namespace.setName(name); + return namespace; + } +} + diff --git a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java index 4ce19f17b..52fda9055 100644 --- a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java +++ b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java @@ -220,7 +220,9 @@ void testExecuteQueries() { () -> repositories.findVersion(userData,"version", "targetPlatform", "extensionName", "namespace"), () -> repositories.findLatestVersion(userData, "namespaceName", "extensionName"), () -> repositories.isDeleteAllVersions("namespaceName", "extensionName", Collections.emptyList(), userData), - () -> repositories.deactivateAccessTokens(userData) + () -> repositories.deactivateAccessTokens(userData), + () -> repositories.findSimilarExtensionsByLevenshtein("extensionName", "namespaceName", "displayName", Collections.emptyList(), 0.5, false, 10), + () -> repositories.findSimilarNamespacesByLevenshtein("namespaceName", Collections.emptyList(), 0.5, false, 10) ); // check that we did not miss anything diff --git a/server/src/test/java/org/eclipse/openvsx/search/SimilarityCheckServiceTest.java b/server/src/test/java/org/eclipse/openvsx/search/SimilarityCheckServiceTest.java new file mode 100644 index 000000000..4940f58c6 --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/search/SimilarityCheckServiceTest.java @@ -0,0 +1,289 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.search; + +import org.eclipse.openvsx.entities.Extension; +import org.eclipse.openvsx.entities.Namespace; +import org.eclipse.openvsx.entities.NamespaceMembership; +import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.util.Streamable; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +/** + * Tests for SimilarityCheckService which handles enforcement policy. + */ +@ExtendWith(MockitoExtension.class) +class SimilarityCheckServiceTest { + + @Mock + SimilarityConfig config; + + @Mock + SimilarityService similarityService; + + @Mock + RepositoryService repositories; + + @InjectMocks + SimilarityCheckService similarityCheckService; + + UserData user; + + @BeforeEach + void setUp() { + user = new UserData(); + user.setLoginName("testuser"); + } + + @Test + void shouldReturnEmptyWhenSimilarityChecksDisabled() { + // When the feature is disabled, no database work should happen. + when(config.isEnabled()).thenReturn(false); + + var result = similarityCheckService.findSimilarExtensionsForPublishing( + "ext", "ns", "Display", user + ); + + assertThat(result).isEmpty(); + verifyNoInteractions(similarityService); + verifyNoInteractions(repositories); + } + + @Test + void shouldExcludeOwnerNamespacesWhenConfigured() { + // When exclude-owner-namespaces is enabled, we should build a list of owner namespaces to exclude. + when(config.isEnabled()).thenReturn(true); + when(config.isExcludeOwnerNamespaces()).thenReturn(true); + when(config.getLevenshteinThreshold()).thenReturn(0.15); + when(config.isCheckAgainstVerifiedOnly()).thenReturn(false); + + var namespace1 = new Namespace(); + namespace1.setName("owned-ns"); + var membership1 = new NamespaceMembership(); + membership1.setNamespace(namespace1); + membership1.setRole(NamespaceMembership.ROLE_OWNER); + + var namespace2 = new Namespace(); + namespace2.setName("contributor-ns"); + var membership2 = new NamespaceMembership(); + membership2.setNamespace(namespace2); + membership2.setRole(NamespaceMembership.ROLE_CONTRIBUTOR); + + when(repositories.findMemberships(user)).thenReturn(Streamable.of(membership1, membership2)); + when(similarityService.findSimilarExtensions("ext", "ns", "Display", List.of("owned-ns"), 0.15, false, 10)) + .thenReturn(List.of()); + + var result = similarityCheckService.findSimilarExtensionsForPublishing( + "ext", "ns", "Display", user + ); + + assertThat(result).isEmpty(); + verify(repositories).findMemberships(user); + verify(similarityService).findSimilarExtensions("ext", "ns", "Display", List.of("owned-ns"), 0.15, false, 10); + } + + @Test + void shouldNotExcludeNamespacesWhenConfigDisabled() { + // When exclude-owner-namespaces is disabled, pass an empty exclude list. + when(config.isEnabled()).thenReturn(true); + when(config.isExcludeOwnerNamespaces()).thenReturn(false); + when(config.getLevenshteinThreshold()).thenReturn(0.15); + when(config.isCheckAgainstVerifiedOnly()).thenReturn(false); + when(similarityService.findSimilarExtensions("ext", "ns", "Display", List.of(), 0.15, false, 10)) + .thenReturn(List.of()); + + var result = similarityCheckService.findSimilarExtensionsForPublishing( + "ext", "ns", "Display", user + ); + + assertThat(result).isEmpty(); + verifyNoInteractions(repositories); + verify(similarityService).findSimilarExtensions("ext", "ns", "Display", List.of(), 0.15, false, 10); + } + + @Test + void shouldDelegateSimilarExtensionsToServiceWhenEnabled() { + // Happy path: when enabled, delegate to SimilarityService with proper parameters. + when(config.isEnabled()).thenReturn(true); + when(config.isExcludeOwnerNamespaces()).thenReturn(false); + when(config.getLevenshteinThreshold()).thenReturn(0.15); + when(config.isCheckAgainstVerifiedOnly()).thenReturn(false); + var expected = List.of(new Extension()); + when(similarityService.findSimilarExtensions("ext", "ns", "Display", List.of(), 0.15, false, 10)) + .thenReturn(expected); + + var result = similarityCheckService.findSimilarExtensionsForPublishing( + "ext", "ns", "Display", user + ); + + assertThat(result).isSameAs(expected); + verify(similarityService).findSimilarExtensions("ext", "ns", "Display", List.of(), 0.15, false, 10); + } + + @Test + void shouldSkipCheckForExistingExtensionWhenConfiguredForNewOnly() { + // When configured for new extensions only, skip if extension already has versions. + when(config.isEnabled()).thenReturn(true); + when(config.isNewExtensionsOnly()).thenReturn(true); + when(repositories.countVersions("ns", "ext")).thenReturn(1); + + var result = similarityCheckService.findSimilarExtensionsForPublishing( + "ext", "ns", "Display", user + ); + + assertThat(result).isEmpty(); + verify(repositories).countVersions("ns", "ext"); + verifyNoInteractions(similarityService); + } + + @Test + void shouldCheckNewExtensionEvenWhenConfiguredForNewOnly() { + // When configured for new extensions only, still check if extension has no versions. + when(config.isEnabled()).thenReturn(true); + when(config.isNewExtensionsOnly()).thenReturn(true); + when(config.getLevenshteinThreshold()).thenReturn(0.15); + when(config.isCheckAgainstVerifiedOnly()).thenReturn(false); + when(repositories.countVersions("ns", "ext")).thenReturn(0); + when(similarityService.findSimilarExtensions("ext", "ns", "Display", List.of(), 0.15, false, 10)) + .thenReturn(List.of()); + + var result = similarityCheckService.findSimilarExtensionsForPublishing( + "ext", "ns", "Display", user + ); + + assertThat(result).isEmpty(); + verify(repositories).countVersions("ns", "ext"); + verify(similarityService).findSimilarExtensions("ext", "ns", "Display", List.of(), 0.15, false, 10); + } + + @Test + void shouldSkipCheckForVerifiedPublisherWhenConfigured() { + // When configured to skip verified publishers, check if namespace has owner memberships. + when(config.isEnabled()).thenReturn(true); + when(config.isSkipVerifiedPublishers()).thenReturn(true); + var namespace = new Namespace(); + when(repositories.findNamespace("ns")).thenReturn(namespace); + when(repositories.hasMemberships(namespace, NamespaceMembership.ROLE_OWNER)).thenReturn(true); + + var result = similarityCheckService.findSimilarExtensionsForPublishing( + "ext", "ns", "Display", user + ); + + assertThat(result).isEmpty(); + verify(repositories).findNamespace("ns"); + verify(repositories).hasMemberships(namespace, NamespaceMembership.ROLE_OWNER); + verifyNoInteractions(similarityService); + } + + @Test + void shouldCheckVerifiedPublisherWhenSkipIsDisabled() { + // When skip verified publishers is disabled, check even if namespace has owner memberships. + when(config.isEnabled()).thenReturn(true); + when(config.isSkipVerifiedPublishers()).thenReturn(false); + when(config.getLevenshteinThreshold()).thenReturn(0.15); + when(config.isCheckAgainstVerifiedOnly()).thenReturn(false); + when(similarityService.findSimilarExtensions("ext", "ns", "Display", List.of(), 0.15, false, 10)) + .thenReturn(List.of()); + + var result = similarityCheckService.findSimilarExtensionsForPublishing( + "ext", "ns", "Display", user + ); + + assertThat(result).isEmpty(); + verify(similarityService).findSimilarExtensions("ext", "ns", "Display", List.of(), 0.15, false, 10); + } + + @Test + void shouldPassConfiguredThresholdAndVerifiedOnlyFlag() { + // Verify that config values are correctly passed to SimilarityService. + when(config.isEnabled()).thenReturn(true); + when(config.isExcludeOwnerNamespaces()).thenReturn(false); + when(config.getLevenshteinThreshold()).thenReturn(0.25); + when(config.isCheckAgainstVerifiedOnly()).thenReturn(true); + when(similarityService.findSimilarExtensions("ext", "ns", "Display", List.of(), 0.25, true, 10)) + .thenReturn(List.of()); + + var result = similarityCheckService.findSimilarExtensionsForPublishing( + "ext", "ns", "Display", user + ); + + assertThat(result).isEmpty(); + verify(similarityService).findSimilarExtensions("ext", "ns", "Display", List.of(), 0.25, true, 10); + } + + @Test + void shouldReturnEmptyNamespacesWhenDisabled() { + // When disabled, namespace creation checks should not hit the database. + when(config.isEnabled()).thenReturn(false); + + var result = similarityCheckService.findSimilarNamespacesForCreation("ns", user); + + assertThat(result).isEmpty(); + verifyNoInteractions(similarityService); + verifyNoInteractions(repositories); + } + + @Test + void shouldDelegateSimilarNamespacesToServiceWhenEnabled() { + // When enabled, delegate to SimilarityService with config parameters. + when(config.isEnabled()).thenReturn(true); + when(config.isExcludeOwnerNamespaces()).thenReturn(false); + when(config.getLevenshteinThreshold()).thenReturn(0.15); + when(config.isCheckAgainstVerifiedOnly()).thenReturn(false); + var expected = List.of(new Namespace()); + when(similarityService.findSimilarNamespaces("ns", List.of(), 0.15, false, 10)) + .thenReturn(expected); + + var result = similarityCheckService.findSimilarNamespacesForCreation("ns", user); + + assertThat(result).isSameAs(expected); + verify(similarityService).findSimilarNamespaces("ns", List.of(), 0.15, false, 10); + } + + @Test + void shouldExcludeOwnerNamespacesForNamespaceCreation() { + // When exclude-owner-namespaces is enabled for namespace creation. + when(config.isEnabled()).thenReturn(true); + when(config.isExcludeOwnerNamespaces()).thenReturn(true); + when(config.getLevenshteinThreshold()).thenReturn(0.2); + when(config.isCheckAgainstVerifiedOnly()).thenReturn(true); + + var namespace1 = new Namespace(); + namespace1.setName("owned-ns"); + var membership1 = new NamespaceMembership(); + membership1.setNamespace(namespace1); + membership1.setRole(NamespaceMembership.ROLE_OWNER); + + when(repositories.findMemberships(user)).thenReturn(Streamable.of(membership1)); + when(similarityService.findSimilarNamespaces("ns", List.of("owned-ns"), 0.2, true, 10)) + .thenReturn(List.of()); + + var result = similarityCheckService.findSimilarNamespacesForCreation("ns", user); + + assertThat(result).isEmpty(); + verify(repositories).findMemberships(user); + verify(similarityService).findSimilarNamespaces("ns", List.of("owned-ns"), 0.2, true, 10); + } +} + diff --git a/server/src/test/java/org/eclipse/openvsx/search/SimilarityServiceTest.java b/server/src/test/java/org/eclipse/openvsx/search/SimilarityServiceTest.java new file mode 100644 index 000000000..a22434d3a --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/search/SimilarityServiceTest.java @@ -0,0 +1,114 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.search; + +import org.eclipse.openvsx.entities.Extension; +import org.eclipse.openvsx.entities.Namespace; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * Tests for SimilarityService - the pure computation engine. + *

+ * These tests verify the core similarity calculation logic without configuration. + * For configuration-based policy tests, see {@link SimilarityCheckServiceTest}. + */ +@ExtendWith(MockitoExtension.class) +class SimilarityServiceTest { + + @Mock + RepositoryService repositories; + + @InjectMocks + SimilarityService similarityService; + + @Test + void shouldReturnEmptyWhenNoInputProvided() { + // When no extension name, namespace, or display name is provided, return empty. + var result = similarityService.findSimilarExtensions(null, null, null, List.of(), 0.15, false, 10); + + assertThat(result).isEmpty(); + verifyNoInteractions(repositories); + } + + @Test + void shouldDelegateToRepositoryWithProvidedParameters() { + // Happy path: delegates to repository with the provided threshold and verifiedOnly flag. + var expected = List.of(new Extension()); + when(repositories.findSimilarExtensionsByLevenshtein("ext", "ns", "Display", List.of(), 0.2, true, 10)) + .thenReturn(expected); + + var result = similarityService.findSimilarExtensions("ext", "ns", "Display", List.of(), 0.2, true, 10); + + assertThat(result).isSameAs(expected); + verify(repositories).findSimilarExtensionsByLevenshtein("ext", "ns", "Display", List.of(), 0.2, true, 10); + } + + @Test + void shouldWrapRepositoryErrorsWhenCheckingExtensions() { + // Repository failures should be wrapped with a user-friendly runtime error. + when(repositories.findSimilarExtensionsByLevenshtein(any(), any(), any(), any(), anyDouble(), anyBoolean(), anyInt())) + .thenThrow(new RuntimeException("db down")); + + assertThatThrownBy(() -> similarityService.findSimilarExtensions("ext", "ns", "Display", List.of(), 0.15, false, 10)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Unable to verify extension name uniqueness"); + } + + @Test + void shouldDelegateNamespaceSimilarityWithProvidedParameters() { + // Happy path: delegates namespace similarity search with provided parameters. + var existing = new Namespace(); + when(repositories.findSimilarNamespacesByLevenshtein("ns", List.of("skip"), 0.1, true, 10)) + .thenReturn(List.of(existing)); + + var result = similarityService.findSimilarNamespaces("ns", List.of("skip"), 0.1, true, 10); + + assertThat(result).containsExactly(existing); + verify(repositories).findSimilarNamespacesByLevenshtein("ns", List.of("skip"), 0.1, true, 10); + } + + @Test + void shouldReturnEmptyForEmptyNamespaceInput() { + // Empty namespace names should not trigger database work. + var result = similarityService.findSimilarNamespaces("", List.of(), 0.15, false, 10); + + assertThat(result).isEmpty(); + verifyNoInteractions(repositories); + } + + @Test + void shouldWrapRepositoryErrorsWhenCheckingNamespaces() { + // Repository failures should be wrapped with a user-friendly runtime error. + when(repositories.findSimilarNamespacesByLevenshtein(any(), any(), anyDouble(), anyBoolean(), anyInt())) + .thenThrow(new RuntimeException("db down")); + + assertThatThrownBy(() -> similarityService.findSimilarNamespaces("ns", List.of(), 0.15, false, 10)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Unable to verify namespace name uniqueness"); + } +} + From 1267fe89d61b9a357cb282091938f68102f7298b Mon Sep 17 00:00:00 2001 From: Alejandro Munoz Date: Tue, 6 Jan 2026 14:48:40 -0500 Subject: [PATCH 2/9] Add Secret Scanning to Publish Workflow (#1510) * Add secret detection service to publish workflow * Add mockito bean for secret scanning --- server/build.gradle | 6 +- server/src/dev/resources/application.yml | 19 +- .../org/eclipse/openvsx/ExtensionService.java | 32 +- .../eclipse/openvsx/scanning/AhoCorasick.java | 202 +++++ .../openvsx/scanning/EntropyCalculator.java | 49 ++ .../scanning/GitleaksRulesGenerator.java | 529 ++++++++++++ .../openvsx/scanning/SecretFinding.java | 88 ++ .../eclipse/openvsx/scanning/SecretRule.java | 200 +++++ .../openvsx/scanning/SecretRuleLoader.java | 336 ++++++++ .../openvsx/scanning/SecretScanResult.java | 78 ++ .../openvsx/scanning/SecretScanner.java | 402 +++++++++ .../scanning/SecretScannerFactory.java | 297 +++++++ .../scanning/SecretScanningConfiguration.java | 337 ++++++++ .../scanning/SecretScanningService.java | 218 +++++ .../org/eclipse/openvsx/util/ArchiveUtil.java | 44 + .../secret-scanning-custom-rules.yaml | 92 +++ .../org/eclipse/openvsx/RegistryAPITest.java | 9 +- .../java/org/eclipse/openvsx/UserAPITest.java | 8 +- .../eclipse/openvsx/admin/AdminAPITest.java | 9 +- .../openvsx/eclipse/EclipseServiceTest.java | 8 +- .../scanning/GitleaksRulesGeneratorTest.java | 254 ++++++ .../scanning/SecretRuleLoaderTest.java | 356 ++++++++ .../scanning/SecretScanResultTest.java | 64 ++ .../scanning/SecretScannerFactoryTest.java | 158 ++++ .../openvsx/scanning/SecretScannerTest.java | 768 ++++++++++++++++++ .../scanning/SecretScanningServiceTest.java | 142 ++++ .../openvsx/scanning/secret-rules-a.yaml | 14 + .../scanning/secret-rules-allowlist-2.yaml | 21 + .../scanning/secret-rules-allowlist-3.yaml | 14 + .../openvsx/scanning/secret-rules-b.yaml | 12 + .../scanning/secret-rules-with-allowlist.yaml | 25 + 31 files changed, 4776 insertions(+), 15 deletions(-) create mode 100644 server/src/main/java/org/eclipse/openvsx/scanning/AhoCorasick.java create mode 100644 server/src/main/java/org/eclipse/openvsx/scanning/EntropyCalculator.java create mode 100644 server/src/main/java/org/eclipse/openvsx/scanning/GitleaksRulesGenerator.java create mode 100644 server/src/main/java/org/eclipse/openvsx/scanning/SecretFinding.java create mode 100644 server/src/main/java/org/eclipse/openvsx/scanning/SecretRule.java create mode 100644 server/src/main/java/org/eclipse/openvsx/scanning/SecretRuleLoader.java create mode 100644 server/src/main/java/org/eclipse/openvsx/scanning/SecretScanResult.java create mode 100644 server/src/main/java/org/eclipse/openvsx/scanning/SecretScanner.java create mode 100644 server/src/main/java/org/eclipse/openvsx/scanning/SecretScannerFactory.java create mode 100644 server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningConfiguration.java create mode 100644 server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningService.java create mode 100644 server/src/main/resources/scanning/secret-scanning-custom-rules.yaml create mode 100644 server/src/test/java/org/eclipse/openvsx/scanning/GitleaksRulesGeneratorTest.java create mode 100644 server/src/test/java/org/eclipse/openvsx/scanning/SecretRuleLoaderTest.java create mode 100644 server/src/test/java/org/eclipse/openvsx/scanning/SecretScanResultTest.java create mode 100644 server/src/test/java/org/eclipse/openvsx/scanning/SecretScannerFactoryTest.java create mode 100644 server/src/test/java/org/eclipse/openvsx/scanning/SecretScannerTest.java create mode 100644 server/src/test/java/org/eclipse/openvsx/scanning/SecretScanningServiceTest.java create mode 100644 server/src/test/resources/org/eclipse/openvsx/scanning/secret-rules-a.yaml create mode 100644 server/src/test/resources/org/eclipse/openvsx/scanning/secret-rules-allowlist-2.yaml create mode 100644 server/src/test/resources/org/eclipse/openvsx/scanning/secret-rules-allowlist-3.yaml create mode 100644 server/src/test/resources/org/eclipse/openvsx/scanning/secret-rules-b.yaml create mode 100644 server/src/test/resources/org/eclipse/openvsx/scanning/secret-rules-with-allowlist.yaml diff --git a/server/build.gradle b/server/build.gradle index e2292eb32..6a24633cb 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -43,7 +43,8 @@ def versions = [ jaxb_impl: '2.3.8', gatling: '3.14.9', loki4j: '1.4.2', - jedis: '6.2.0' + jedis: '6.2.0', + re2j: '1.7' ] ext['junit-jupiter.version'] = versions.junit java { @@ -130,11 +131,14 @@ dependencies { implementation "com.fasterxml.jackson.module:jackson-module-jaxb-annotations:${versions.jackson}" implementation "com.fasterxml.woodstox:woodstox-core:${versions.woodstox}" implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-xml:${versions.jackson}" + implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${versions.jackson}" + implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-toml:${versions.jackson}" implementation "javax.xml.bind:jaxb-api:${versions.jaxb_api}" implementation "com.sun.xml.bind:jaxb-impl:${versions.jaxb_impl}" implementation "org.apache.commons:commons-lang3:${versions.commons_lang3}" implementation "org.apache.httpcomponents.client5:httpclient5" implementation "org.apache.tika:tika-core:${versions.tika}" + implementation "com.google.re2j:re2j:${versions.re2j}" implementation "com.github.loki4j:loki-logback-appender:${versions.loki4j}" implementation "io.micrometer:micrometer-tracing" implementation "io.micrometer:micrometer-tracing-bridge-otel" diff --git a/server/src/dev/resources/application.yml b/server/src/dev/resources/application.yml index 81e51cfef..b8d2b5f7c 100644 --- a/server/src/dev/resources/application.yml +++ b/server/src/dev/resources/application.yml @@ -176,4 +176,21 @@ ovsx: skip-verified-publishers: true check-against-verified-only: true exclude-owner-namespaces: true - new-extensions-only: true \ No newline at end of file + new-extensions-only: true + secret-scanning: + enabled: true + rules-path: 'classpath:scanning/secret-scanning-custom-rules.yaml' + inline-suppressions: 'secret-scanner:ignore,gitleaks:allow,nosecret,@suppress-secret' + auto-generate-rules: true + force-regenerate-rules: false + generated-rules-path: '/tmp/secret-scanning-rules-gitleaks.yaml' + max-file-size-bytes: 5242880 + max-entry-count: 5000 + max-total-uncompressed-bytes: 104857600 + max-findings: 200 + max-line-length: 10000 + long-line-no-space-threshold: 1000 + keyword-context-chars: 100 + log-secret-preview-chars: 10 + timeout-seconds: 5 + timeout-check-every-n-lines: 100 diff --git a/server/src/main/java/org/eclipse/openvsx/ExtensionService.java b/server/src/main/java/org/eclipse/openvsx/ExtensionService.java index 96b835d3f..fb39f02bf 100644 --- a/server/src/main/java/org/eclipse/openvsx/ExtensionService.java +++ b/server/src/main/java/org/eclipse/openvsx/ExtensionService.java @@ -22,6 +22,7 @@ import org.eclipse.openvsx.json.TargetPlatformVersionJson; import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.scanning.SecretScanningService; import org.eclipse.openvsx.search.SearchUtilService; import org.eclipse.openvsx.util.ErrorResultException; import org.eclipse.openvsx.util.NamingUtil; @@ -55,6 +56,7 @@ public class ExtensionService { private final CacheService cache; private final PublishExtensionVersionHandler publishHandler; private final JobRequestScheduler scheduler; + private final SecretScanningService secretScanningService; @Value("${ovsx.publishing.require-license:false}") boolean requireLicense; @@ -68,7 +70,8 @@ public ExtensionService( SearchUtilService search, CacheService cache, PublishExtensionVersionHandler publishHandler, - JobRequestScheduler scheduler + JobRequestScheduler scheduler, + SecretScanningService secretScanningService ) { this.entityManager = entityManager; this.repositories = repositories; @@ -76,6 +79,7 @@ public ExtensionService( this.cache = cache; this.publishHandler = publishHandler; this.scheduler = scheduler; + this.secretScanningService = secretScanningService; } @Transactional @@ -95,6 +99,32 @@ public ExtensionVersion publishVersion(InputStream content, PersonalAccessToken } private void doPublish(TempFile extensionFile, String binaryName, PersonalAccessToken token, LocalDateTime timestamp, boolean checkDependencies) { + // Scan for secrets before processing the extension + // This fails fast if secrets are detected, preventing publication + var scanResult = secretScanningService.scanForSecrets(extensionFile); + if (scanResult.isSecretsFound()) { + var findings = scanResult.getFindings(); + var errorMessage = new StringBuilder(); + errorMessage.append("Extension publication blocked: potential secrets detected in the package.\n\n"); + errorMessage.append("The following potential secrets were found:\n"); + + int maxFindings = Math.min(5, findings.size()); + for (int i = 0; i < maxFindings; i++) { + errorMessage.append(" ").append(i + 1).append(". ").append(findings.get(i).toString()).append("\n"); + } + + if (findings.size() > maxFindings) { + errorMessage.append(" ... and ").append(findings.size() - maxFindings).append(" more\n"); + } + + errorMessage.append("\nPlease remove these secrets before publishing. "); + errorMessage.append("Consider using environment variables or configuration files that are not included in the package. "); + + errorMessage.append("Refer to the publishing guidelines: https://github.com/EclipseFdn/open-vsx.org/wiki/Publishing-Extensions"); + + throw new ErrorResultException(errorMessage.toString()); + } + try (var processor = new ExtensionProcessor(extensionFile)) { var extVersion = publishHandler.createExtensionVersion(processor, token, timestamp, checkDependencies); if (requireLicense) { diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/AhoCorasick.java b/server/src/main/java/org/eclipse/openvsx/scanning/AhoCorasick.java new file mode 100644 index 000000000..27da0ee5b --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/scanning/AhoCorasick.java @@ -0,0 +1,202 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import jakarta.validation.constraints.NotNull; +import java.util.*; + +/** + * Aho-Corasick string matching automaton for efficient multi-pattern search. + * + * This implementation allows searching for thousands of keywords in linear time. + * Instead of running hundreds of regexes on the entire file, we: + * 1. Build a trie (prefix tree) from all keywords + * 2. Add failure links for efficient state transitions + * 3. Search the text once to find all keyword matches in O(n + m + z) time + * where n = text length, m = total pattern length, z = number of matches + * + * Based on the algorithm used by TruffleHog for secret detection optimization. + */ +public class AhoCorasick { + + /** + * Node in the Aho-Corasick trie. + * Each node represents a state in the automaton. + */ + private static class TrieNode { + // Children nodes indexed by character + Map children = new HashMap<>(); + + // Failure link - where to go if no match found + // This is the key to Aho-Corasick's efficiency + TrieNode failure = null; + + // Output patterns at this node (if this node ends a pattern) + List outputs = new ArrayList<>(); + } + + private final TrieNode root; + + public AhoCorasick() { + this.root = new TrieNode(); + } + + /** + * Build the automaton from a collection of keywords. + * This should be called once with all patterns before searching. + * + * @param keywords Set of keywords to search for (should be lowercase for case-insensitive matching) + */ + public void build(@NotNull Set keywords) { + // Step 1: Build the trie (prefix tree) + // Add all keywords to the trie structure + for (String keyword : keywords) { + if (keyword == null || keyword.isEmpty()) { + continue; + } + addKeyword(keyword); + } + + // Step 2: Build failure links using BFS + // Failure links allow us to continue matching after a mismatch + // without backtracking in the text + buildFailureLinks(); + } + + /** + * Add a single keyword to the trie. + */ + private void addKeyword(String keyword) { + TrieNode current = root; + + for (char c : keyword.toCharArray()) { + current = current.children.computeIfAbsent(c, k -> new TrieNode()); + } + + current.outputs.add(keyword); + } + + /** + * Build failure links for all nodes using BFS. + * Failure links point to the longest proper suffix that is also a prefix of some pattern. + * This is what makes Aho-Corasick efficient - we never backtrack in the input text. + */ + private void buildFailureLinks() { + Queue queue = new LinkedList<>(); + + // Initialize: all children of root fail back to root + for (TrieNode child : root.children.values()) { + child.failure = root; + queue.add(child); + } + + // BFS to build failure links for all nodes + while (!queue.isEmpty()) { + TrieNode current = queue.poll(); + + for (Map.Entry entry : current.children.entrySet()) { + char c = entry.getKey(); + TrieNode child = entry.getValue(); + queue.add(child); + + // Find the failure link for this child + // Walk up failure links until we find a node that has a child for 'c' + TrieNode failNode = current.failure; + while (failNode != null && !failNode.children.containsKey(c)) { + failNode = failNode.failure; + } + + if (failNode == null) { + // No suffix match found, fail to root + child.failure = root; + } else { + // Found a suffix match + child.failure = failNode.children.get(c); + } + + // Copy outputs from failure node (for overlapping patterns) + if (child.failure != null && child.failure != root) { + child.outputs.addAll(child.failure.outputs); + } + } + } + } + + /** + * Search for all keyword matches in the given text. + * Returns a list of matches with their positions. + * + * @param text Text to search in (should be lowercase for case-insensitive matching) + * @return List of all keyword matches found + */ + public @NotNull List search(@NotNull String text) { + List matches = new ArrayList<>(); + TrieNode current = root; + + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + + // Follow failure links until we find a match or reach root + while (current != root && !current.children.containsKey(c)) { + current = current.failure; + } + + // Move to next state if possible + if (current.children.containsKey(c)) { + current = current.children.get(c); + } + + // If this node has outputs, we found matches + for (String keyword : current.outputs) { + // Calculate the start position of the match + int startPos = i - keyword.length() + 1; + matches.add(new Match(keyword, startPos, i + 1)); + } + } + + return matches; + } + + /** + * Represents a keyword match in the text. + */ + public static class Match { + private final String keyword; + private final int startPos; + private final int endPos; + + public Match(@NotNull String keyword, int startPos, int endPos) { + this.keyword = keyword; + this.startPos = startPos; + this.endPos = endPos; + } + + public @NotNull String getKeyword() { + return keyword; + } + + public int getStartPos() { + return startPos; + } + + public int getEndPos() { + return endPos; + } + + @Override + public String toString() { + return String.format("Match{keyword='%s', pos=[%d,%d)}", keyword, startPos, endPos); + } + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/EntropyCalculator.java b/server/src/main/java/org/eclipse/openvsx/scanning/EntropyCalculator.java new file mode 100644 index 000000000..034abe513 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/scanning/EntropyCalculator.java @@ -0,0 +1,49 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import javax.annotation.Nullable; +import java.util.HashMap; +import java.util.Map; + +/** + * Calculates Shannon entropy for strings. + * Higher entropy implies a more random string, which is often indicative of secrets. + */ +public class EntropyCalculator { + + /** + * Calculate Shannon entropy: H = -Σ(p(x) * log2(p(x))). + */ + public double calculate(@Nullable String input) { + if (input == null || input.isEmpty()) { + return 0.0; + } + + Map frequencies = new HashMap<>(); + for (char c : input.toCharArray()) { + frequencies.put(c, frequencies.getOrDefault(c, 0) + 1); + } + + double entropy = 0.0; + int length = input.length(); + + for (int count : frequencies.values()) { + double probability = (double) count / length; + entropy -= probability * (Math.log(probability) / Math.log(2)); + } + + return entropy; + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/GitleaksRulesGenerator.java b/server/src/main/java/org/eclipse/openvsx/scanning/GitleaksRulesGenerator.java new file mode 100644 index 000000000..a63596cd0 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/scanning/GitleaksRulesGenerator.java @@ -0,0 +1,529 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.toml.TomlMapper; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Generates secret scanning rules from gitleaks.toml at application startup. + * + * This component downloads the official gitleaks configuration and converts it to + * the YAML format used by OpenVSX secret scanning. It runs before {@link SecretScannerFactory} + * to ensure rules are available when secret scanning initializes. + * + * Generation is controlled by application configuration: + * - ovsx.secret-scanning.enabled: Must be true to generate rules + * - ovsx.secret-scanning.auto-generate-rules: Enable automatic generation (default: false) + * - ovsx.secret-scanning.force-regenerate-rules: Force regeneration even if file exists (default: false) + */ +@Component +public class GitleaksRulesGenerator { + + private static final Logger logger = LoggerFactory.getLogger(GitleaksRulesGenerator.class); + + private static final String GITLEAKS_URL = + "https://raw.githubusercontent.com/gitleaks/gitleaks/master/config/gitleaks.toml"; + + /** + * List of rule IDs to skip during conversion. + * These rules produce too many false positives in the extension ecosystem. + */ + private static final Set SKIP_RULE_IDS = Set.of("generic-api-key"); + + private final SecretScanningConfiguration config; + + /** + * Path to the generated rules file, if generation succeeded. + * Other components can use this to load the generated file. + */ + private String generatedRulesPath; + + public GitleaksRulesGenerator(SecretScanningConfiguration config) { + this.config = config; + } + + /** + * Get the path to the generated rules file, or null if not generated. + * @return Absolute path to the generated file, or null if generation was skipped/failed + */ + public String getGeneratedRulesPath() { + return generatedRulesPath; + } + + /** + * Generate gitleaks rules at startup if configured to do so. + * + * Throws an exception if generation is enabled and fails, causing the application to fail to start. + * This prevents the application from running with missing or invalid secret scanning rules. + */ + @PostConstruct + public void generateRulesIfNeeded() { + // Skip if auto-generation is disabled + if (!config.isAutoGenerateRules()) { + logger.info("Auto-generation of secret rules is disabled"); + return; + } + + try { + File outputFile = resolveOutputFile(); + + if (outputFile == null) { + throw new IllegalStateException("Cannot resolve output file for generated rules"); + } + + // Store the path so other components can load it + this.generatedRulesPath = outputFile.getAbsolutePath(); + + // Check if file already exists + if (outputFile.exists() && !config.isForceRegenerateRules()) { + logger.info("Secret rules file already exists: {}", + outputFile.getName()); + return; + } + + // Generate rules + String action = outputFile.exists() ? "Regenerating" : "Generating"; + logger.info("{} secret scanning rules from gitleaks.toml...", action); + + generateRules(outputFile.toPath()); + + // Verify the file was created successfully + if (!outputFile.exists()) { + throw new IllegalStateException( + "Failed to generate secret scanning rules file: " + outputFile.getAbsolutePath()); + } + + if (outputFile.length() == 0) { + throw new IllegalStateException( + "Generated secret scanning rules file is empty: " + outputFile.getAbsolutePath()); + } + + logger.info("Generated secret scanning rules: {} ({} bytes)", + outputFile.getName(), outputFile.length()); + + } catch (Exception e) { + logger.error("Failed to generate secret scanning rules", e); + throw new IllegalStateException( + "Secret scanning rule generation failed. " + + "Either fix the network/configuration issue or disable auto-generation " + + "(set ovsx.secret-scanning.auto-generate-rules=false)", e); + } + } + + /** + * Main conversion logic: download, parse, and write YAML. + */ + private void generateRules(Path outputPath) throws IOException, InterruptedException { + // Download TOML + logger.info("Downloading gitleaks.toml from: {}", GITLEAKS_URL); + String tomlContent = downloadGitleaksToml(); + + GitleaksToml parsed = parseTomlWithJackson(tomlContent); + + List rules = buildRules(parsed.rules); + + GlobalAllowlist allowlist = extractGlobalAllowlist(parsed.allowlist); + + GitleaksConfig configDto = new GitleaksConfig(); + configDto.rules = rules; + configDto.allowlist = allowlist; + + logger.info("Writing YAML to: {}", outputPath); + writeYaml(configDto, outputPath); + } + + /** + * Download gitleaks.toml from the official repository. + */ + private String downloadGitleaksToml() throws IOException, InterruptedException { + HttpClient client = HttpClient.newHttpClient(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(GITLEAKS_URL)) + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + throw new IOException("Failed to download gitleaks.toml: HTTP " + response.statusCode()); + } + + String body = response.body(); + if (body == null || body.isEmpty()) { + throw new IOException("Downloaded gitleaks.toml is empty"); + } + + return body; + } + + /** + * Parse TOML using Jackson's TomlMapper. + */ + private GitleaksToml parseTomlWithJackson(String tomlContent) throws IOException { + TomlMapper tomlMapper = new TomlMapper(); + return tomlMapper.readValue(tomlContent, GitleaksToml.class); + } + + /** + * Build rules from parsed TOML, filtering and normalizing. + */ + private List buildRules(List rawRules) { + if (rawRules == null || rawRules.isEmpty()) { + return new ArrayList<>(); + } + + List result = new ArrayList<>(); + int skipped = 0; + + for (RawRule raw : rawRules) { + // Skip rules known to cause false positives + if (raw.id != null && SKIP_RULE_IDS.contains(raw.id)) { + logger.info("Skipping rule: {} (known to cause false positives)", raw.id); + skipped++; + continue; + } + + Rule normalized = normalizeRule(raw); + if (normalized != null) { + result.add(normalized); + } + } + + logger.info("Parsed {} rules (skipped {})", result.size(), skipped); + return result; + } + + /** + * Extract global allowlist from parsed TOML. + */ + private GlobalAllowlist extractGlobalAllowlist(RawAllowlist raw) { + if (raw == null) { + return new GlobalAllowlist(); + } + + GlobalAllowlist allowlist = new GlobalAllowlist(); + allowlist.paths = raw.paths != null ? raw.paths : new ArrayList<>(); + allowlist.regexes = raw.regexes != null ? raw.regexes : new ArrayList<>(); + allowlist.stopwords = raw.stopwords != null ? raw.stopwords : new ArrayList<>(); + allowlist.fileExtensions = raw.getFileExtensions() != null ? raw.getFileExtensions() : new ArrayList<>(); + + return allowlist; + } + + /** + * Normalize a single gitleaks rule into the YAML DTO shape we load at runtime. + */ + private Rule normalizeRule(RawRule raw) { + if (raw == null || raw.id == null || raw.regex == null) { + return null; + } + + Rule rule = new Rule(); + rule.id = raw.id; + rule.description = raw.description != null ? raw.description : ""; + rule.regex = raw.regex; + rule.entropy = raw.entropy; + rule.secretGroup = raw.secretGroup; + + // Normalize keywords to lowercase + if (raw.keywords != null && !raw.keywords.isEmpty()) { + rule.keywords = raw.keywords.stream() + .map(String::toLowerCase) + .collect(Collectors.toList()); + } else { + rule.keywords = new ArrayList<>(); + } + + // Collect allowlists (only regexes are supported) + List rawAllowlists = raw.getAllowlists(); + if (!rawAllowlists.isEmpty()) { + rule.allowlists = new ArrayList<>(); + for (RawAllowlist rawAllowlist : rawAllowlists) { + if (rawAllowlist.regexes != null && !rawAllowlist.regexes.isEmpty()) { + RuleAllowlist allowlist = new RuleAllowlist(); + allowlist.regexes = rawAllowlist.regexes; + rule.allowlists.add(allowlist); + } + } + } + + return rule; + } + + /** + * Write the configuration as YAML with proper formatting. + */ + private void writeYaml(GitleaksConfig config, Path outputPath) throws IOException { + // Add header comments manually + StringBuilder yaml = new StringBuilder(); + yaml.append("# Auto-generated at runtime by GitleaksRulesGenerator.java\n"); + yaml.append("# Do not edit this file manually; regenerate from gitleaks.toml instead.\n"); + yaml.append("\n"); + yaml.append("# Global allowlist - applies to all rules\n"); + yaml.append("allowlist:\n"); + + // Render global allowlist with proper indentation (2 spaces for nested keys) + if (!config.allowlist.paths.isEmpty()) { + yaml.append(" paths:\n"); + for (String path : config.allowlist.paths) { + yaml.append(" - ").append(quote(path)).append("\n"); + } + } + + if (!config.allowlist.regexes.isEmpty()) { + yaml.append(" regexes:\n"); + for (String regex : config.allowlist.regexes) { + yaml.append(" - ").append(quote(regex)).append("\n"); + } + } + + if (!config.allowlist.stopwords.isEmpty()) { + yaml.append(" stopwords:\n"); + for (String word : config.allowlist.stopwords) { + yaml.append(" - ").append(quote(word)).append("\n"); + } + } + + if (!config.allowlist.fileExtensions.isEmpty()) { + yaml.append(" file-extensions:\n"); + for (String ext : config.allowlist.fileExtensions) { + yaml.append(" - ").append(quote(ext)).append("\n"); + } + } + + // Render rules + yaml.append("\nrules:\n"); + for (Rule rule : config.rules) { + yaml.append(" - id: ").append(rule.id).append("\n"); + yaml.append(" description: ").append(quote(rule.description)).append("\n"); + yaml.append(" regex: ").append(quote(rule.regex)).append("\n"); + + if (rule.entropy != null) { + yaml.append(" entropy: ").append(rule.entropy).append("\n"); + } + + if (rule.secretGroup != null) { + yaml.append(" secretGroup: ").append(rule.secretGroup).append("\n"); + } + + if (rule.keywords != null && !rule.keywords.isEmpty()) { + yaml.append(" keywords:\n"); + for (String kw : rule.keywords) { + yaml.append(" - ").append(quote(kw)).append("\n"); + } + } else { + yaml.append(" keywords: []\n"); + } + + // Only include allowlists when present + if (rule.allowlists != null && !rule.allowlists.isEmpty()) { + yaml.append(" allowlists:\n"); + for (RuleAllowlist allowlist : rule.allowlists) { + if (allowlist.regexes != null && !allowlist.regexes.isEmpty()) { + yaml.append(" - regexes:\n"); + for (String regex : allowlist.regexes) { + yaml.append(" - ").append(quote(regex)).append("\n"); + } + } + } + } + } + + Files.writeString(outputPath, yaml.toString()); + } + + /** + * Quote a string for YAML if needed. + * Uses JSON escaping which is compatible with YAML. + */ + private String quote(String value) { + if (value == null) { + return "\"\""; + } + + // Use JSON escaping which is compatible with YAML + try { + ObjectMapper jsonMapper = new ObjectMapper(); + return jsonMapper.writeValueAsString(value); + } catch (Exception e) { + // Fallback to simple quoting + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + } + + /** + * Resolve the output file path for the generated rules. + * + * Uses the path configured in ovsx.secret-scanning.generated-rules-path. + * Fails fast with a clear error if not configured or not writable. + */ + private File resolveOutputFile() { + String path = config.getGeneratedRulesPath(); + + // Fail fast if not configured + if (path == null || path.trim().isEmpty()) { + throw new IllegalStateException( + "Secret scanning rule generation is enabled but 'ovsx.secret-scanning.generated-rules-path' is not configured. " + + "Please set this property to the full path where rules should be written (e.g., /app/data/secret-scanning-rules-gitleaks.yaml)" + ); + } + + File outputFile = new File(path); + + // Create parent directories if they don't exist + File parentDir = outputFile.getParentFile(); + if (parentDir != null && !parentDir.exists()) { + try { + Files.createDirectories(parentDir.toPath()); + logger.info("Created directory for generated rules: {}", parentDir.getAbsolutePath()); + } catch (IOException e) { + throw new IllegalStateException( + "Cannot create directory for generated rules: " + parentDir.getAbsolutePath() + + ". Check file permissions and path configuration.", e); + } + } + + // Verify parent directory is writable + if (parentDir != null && !parentDir.canWrite()) { + throw new IllegalStateException( + "Cannot write to directory for generated rules: " + parentDir.getAbsolutePath() + + ". Check file permissions."); + } + + logger.info("Using configured path for generated rules: {}", outputFile.getAbsolutePath()); + return outputFile; + } + + // ======================================================================================== + // Data structures for Jackson TOML parsing and YAML output + // ======================================================================================== + + /** + * Root TOML structure as parsed by Jackson. + * Ignores unknown properties like "title" that we don't need. + */ + @JsonIgnoreProperties(ignoreUnknown = true) + static class GitleaksToml { + public List rules; + public RawAllowlist allowlist; + } + + /** + * Raw rule as parsed from TOML (before normalization). + * Ignores unknown properties that we don't use. + * Gitleaks TOML can use either "allowlist" or "allowlists". + */ + @JsonIgnoreProperties(ignoreUnknown = true) + static class RawRule { + public String id; + public String description; + public String regex; + public Double entropy; + public Integer secretGroup; + public List keywords; + public List allowlist; + public List allowlists; + + /** + * Helper to get allowlists as a list, checking both singular and plural forms. + * There is the potential for migration from allowlist to allowlists according to gitleaks.toml. + */ + List getAllowlists() { + if (allowlists != null && !allowlists.isEmpty()) { + return allowlists; + } + if (allowlist != null && !allowlist.isEmpty()) { + return allowlist; + } + return new ArrayList<>(); + } + } + + /** + * Raw allowlist from TOML (both global and rule-level). + * Ignores unknown properties that we don't use. + */ + @JsonIgnoreProperties(ignoreUnknown = true) + static class RawAllowlist { + public List paths; + public List regexes; + public List stopwords; + public List file_extensions; + + /** + * Alias for Java code to use camelCase. + */ + List getFileExtensions() { + return file_extensions; + } + } + + /** + * Configuration container for output. + */ + static class GitleaksConfig { + List rules; + GlobalAllowlist allowlist; + } + + /** + * Normalized rule for output. + */ + static class Rule { + String id; + String description; + String regex; + Double entropy; + Integer secretGroup; + List keywords; + List allowlists; + } + + /** + * Rule-level allowlist for output. + */ + static class RuleAllowlist { + List regexes; + } + + /** + * Global allowlist configuration for output. + */ + static class GlobalAllowlist { + List paths = new ArrayList<>(); + List regexes = new ArrayList<>(); + List stopwords = new ArrayList<>(); + List fileExtensions = new ArrayList<>(); + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/SecretFinding.java b/server/src/main/java/org/eclipse/openvsx/scanning/SecretFinding.java new file mode 100644 index 000000000..fbdea274c --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/scanning/SecretFinding.java @@ -0,0 +1,88 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import jakarta.validation.constraints.NotNull; +import javax.annotation.Nullable; +/** + * Represents a single finding discovered during secret scanning. + * Secrets are redacted immediately to avoid accidental leakage. + */ +public final class SecretFinding { + + private final @NotNull String filePath; + private final int lineNumber; + private final double entropy; + private final @NotNull String redactedSecret; + private final @NotNull String ruleId; + + public SecretFinding(@NotNull String filePath, + int lineNumber, + double entropy, + @Nullable String secretValue, + @NotNull String ruleId) { + this.filePath = filePath; + this.lineNumber = lineNumber; + this.entropy = entropy; + this.redactedSecret = redactSecret(secretValue); + this.ruleId = ruleId; + } + + public @NotNull String getFilePath() { + return filePath; + } + + public int getLineNumber() { + return lineNumber; + } + + public double getEntropy() { + return entropy; + } + + public @NotNull String getSecretValue() { + return redactedSecret; + } + + public @NotNull String getRuleId() { + return ruleId; + } + + @Override + public String toString() { + return String.format( + "Potential secret found in %s:%d (entropy: %.2f, rule: %s): %s", + filePath, + lineNumber, + entropy, + ruleId, + redactedSecret + ); + } + + private static @NotNull String redactSecret(@Nullable String secret) { + if (secret == null || secret.length() <= 6) { + return "***"; + } + + // Show first 3 and last 3 characters to not leak content. + int prefixLen = Math.min(3, secret.length() / 3); + int suffixLen = Math.min(3, secret.length() / 3); + + String prefix = secret.substring(0, prefixLen); + String suffix = secret.substring(secret.length() - suffixLen); + + return prefix + "***" + suffix; + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/SecretRule.java b/server/src/main/java/org/eclipse/openvsx/scanning/SecretRule.java new file mode 100644 index 000000000..2bb0a5535 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/scanning/SecretRule.java @@ -0,0 +1,200 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import com.google.re2j.Pattern; +import jakarta.validation.constraints.NotNull; +import javax.annotation.Nullable; + +import java.util.List; + +/** + * Single secret rule definition plus builder. + * + * A secret rule defines: + * + * A regex pattern to match potential secrets + * Optional keywords to filter lines before applying the regex (performance optimization) + * Optional entropy threshold to reduce false positives + * Optional allowlist patterns to exclude known safe matches + * Optional capture group specification to extract the actual secret value + * + * + * Rules are loaded from YAML files and compiled into efficient Pattern objects. + */ +public class SecretRule { + /** Unique identifier for this rule (e.g., "github-pat", "aws-access-key") */ + private final @NotNull String id; + + /** Human-readable description of what this rule detects */ + private final @NotNull String description; + + /** Compiled regex pattern to match secrets (case-insensitive) */ + private final @NotNull Pattern pattern; + + /** Minimum Shannon entropy threshold (0.0-8.0). If set, matches below this are filtered out. */ + private final @Nullable Double entropy; + + /** Keywords that must appear in a line before applying the regex (lowercase, for performance) */ + private final @NotNull List keywords; + + /** Compiled allowlist patterns to exclude known safe matches from this rule */ + private final @NotNull List allowlistPatterns; + + /** Optional capture group index to extract the secret (default: 1 if available, else 0) */ + private final @Nullable Integer secretGroup; + + private SecretRule(@NotNull Builder builder) { + this.id = builder.id; + this.description = builder.description; + this.pattern = Pattern.compile(builder.regex, Pattern.CASE_INSENSITIVE); + this.entropy = builder.entropy; + this.secretGroup = builder.secretGroup; + + if (builder.keywords != null) { + var lowerKeywords = new java.util.ArrayList(); + for (String k : builder.keywords) { + lowerKeywords.add(k.toLowerCase()); + } + this.keywords = List.copyOf(lowerKeywords); + } else { + this.keywords = List.of(); + } + if (builder.allowlistRegexes != null && !builder.allowlistRegexes.isEmpty()) { + var list = new java.util.ArrayList(); + for (String regex : builder.allowlistRegexes) { + list.add(Pattern.compile(regex, Pattern.CASE_INSENSITIVE)); + } + this.allowlistPatterns = List.copyOf(list); + } else { + this.allowlistPatterns = List.of(); + } + } + + public @NotNull String getId() { + return id; + } + + public @NotNull String getDescription() { + return description; + } + + public @NotNull Pattern getPattern() { + return pattern; + } + + public @Nullable Double getEntropy() { + return entropy; + } + + public @NotNull List getKeywords() { + return keywords; + } + + public @NotNull List getAllowlistPatterns() { + return allowlistPatterns; + } + + /** + * Optional capture group index to extract the secret from. + * When absent, the scanner falls back to group 1 when available, otherwise group 0. + */ + public @Nullable Integer getSecretGroup() { + return secretGroup; + } + + /** + * Builder for constructing SecretRule instances. + */ + public static class Builder { + private String id; + private String description; + private String regex; + private Double entropy; + private List keywords; + private List allowlistRegexes; + private Integer secretGroup; + + /** + * Set the unique rule identifier. + */ + public Builder id(String id) { + this.id = id; + return this; + } + + /** + * Set the human-readable description. + */ + public Builder description(String description) { + this.description = description; + return this; + } + + /** + * Set the regex pattern to match secrets. + * Pattern will be compiled as case-insensitive. + */ + public Builder regex(String regex) { + this.regex = regex; + return this; + } + + /** + * Set the minimum Shannon entropy threshold. + * Matches with entropy below this value are filtered out as likely false positives. + */ + public Builder entropy(double entropy) { + this.entropy = entropy; + return this; + } + + /** + * Set keywords that must appear in a line before applying the regex. + * This is a performance optimization - only lines containing at least one keyword + * will have the regex applied. Keywords are case-insensitive. + */ + public Builder keywords(String... keywords) { + this.keywords = List.of(keywords); + return this; + } + + /** + * Set allowlist regex patterns for this rule. + * Matches that also match any allowlist pattern are excluded as known safe values. + */ + public Builder allowlistRegexes(List allowlistRegexes) { + this.allowlistRegexes = allowlistRegexes; + return this; + } + + /** + * Set the capture group index to extract the secret value. + * If not set, defaults to group 1 if available, otherwise group 0 (full match). + */ + public Builder secretGroup(Integer secretGroup) { + this.secretGroup = secretGroup; + return this; + } + + /** + * Build the SecretRule instance. + */ + public SecretRule build() { + if (id == null || regex == null) { + throw new IllegalStateException("Rule must have id and regex"); + } + return new SecretRule(this); + } + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/SecretRuleLoader.java b/server/src/main/java/org/eclipse/openvsx/scanning/SecretRuleLoader.java new file mode 100644 index 000000000..97bb8d6c8 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/scanning/SecretRuleLoader.java @@ -0,0 +1,336 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import jakarta.validation.constraints.NotNull; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Loads secret detection rules from a YAML file. + */ +@Component +public class SecretRuleLoader { + + private static final Logger logger = LoggerFactory.getLogger(SecretRuleLoader.class); + private final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); + + /** + * Container for loaded rules and global allowlist configuration. + * + * This is returned by {@link #loadAll(List)} and contains: + * Compiled secret detection rules from all YAML files (deduplicated by ID) + * Global allowlist configuration from the last YAML file + */ + public static class LoadedRules { + private final List rules; + private final GlobalAllowlist globalAllowlist; + + /** + * Create a container for loaded rules and global allowlist. + */ + public LoadedRules(@NotNull List rules, @Nullable GlobalAllowlist globalAllowlist) { + this.rules = rules; + this.globalAllowlist = globalAllowlist; + } + + /** + * Get the compiled secret detection rules. + */ + public @NotNull List getRules() { + return rules; + } + + /** + * Get the global allowlist configuration. + */ + public @Nullable GlobalAllowlist getGlobalAllowlist() { + return globalAllowlist; + } + } + + /** + * Load rules from a single path. Supports classpath: prefixed resources or absolute/relative file paths. + */ + public List load(@NotNull String path) { + return loadAll(List.of(path)).getRules(); + } + + /** + * Load rules and global allowlist from multiple YAML files. + * Later files override earlier ones by rule id. + * Global allowlists are merged from all files. + */ + public LoadedRules loadAll(@NotNull List paths) { + if (paths.isEmpty()) { + var message = "Secret scanning rules path list is empty"; + logger.warn(message); + return new LoadedRules(List.of(), null); + } + + Map merged = new LinkedHashMap<>(); + List allPaths = new ArrayList<>(); + List allRegexes = new ArrayList<>(); + List allStopwords = new ArrayList<>(); + List allFileExtensions = new ArrayList<>(); + + for (String path : paths) { + RuleFileData loaded = loadSingle(path); + for (SecretRule rule : loaded.rules) { + // Last one wins to allow override behavior. + merged.put(rule.getId(), rule); + } + // Merge global allowlist items from all files + if (loaded.globalAllowlist != null) { + if (loaded.globalAllowlist.paths != null) { + allPaths.addAll(loaded.globalAllowlist.paths); + } + if (loaded.globalAllowlist.regexes != null) { + allRegexes.addAll(loaded.globalAllowlist.regexes); + } + if (loaded.globalAllowlist.stopwords != null) { + allStopwords.addAll(loaded.globalAllowlist.stopwords); + } + if (loaded.globalAllowlist.fileExtensions != null) { + allFileExtensions.addAll(loaded.globalAllowlist.fileExtensions); + } + } + } + + // Create combined global allowlist if any items were found + GlobalAllowlist combinedAllowlist = null; + if (!allPaths.isEmpty() || !allRegexes.isEmpty() || !allStopwords.isEmpty() || !allFileExtensions.isEmpty()) { + combinedAllowlist = new GlobalAllowlist(); + combinedAllowlist.paths = allPaths; + combinedAllowlist.regexes = allRegexes; + combinedAllowlist.stopwords = allStopwords; + combinedAllowlist.fileExtensions = allFileExtensions; + } + + logger.info("Loaded {} rules from {} YAML files", merged.size(), paths.size()); + return new LoadedRules(List.copyOf(merged.values()), combinedAllowlist); + } + + /** + * Internal container for data loaded from a single YAML file. + */ + private static class RuleFileData { + final List rules; + final GlobalAllowlist globalAllowlist; + + RuleFileData(List rules, GlobalAllowlist globalAllowlist) { + this.rules = rules; + this.globalAllowlist = globalAllowlist; + } + } + + private RuleFileData loadSingle(@NotNull String path) { + // Fail when we cannot read rules and scanning is enabled. + if (path.isBlank()) { + var message = "Secret scanning rules path is empty"; + logger.error(message); + throw new IllegalStateException(message); + } + + try (InputStream is = openStream(path)) { + if (is == null) { + var message = "Secret scanning rules YAML not found at '" + path + "'"; + logger.error(message); + throw new IllegalStateException(message); + } + + RuleFile ruleFile = yamlMapper.readValue(is, RuleFile.class); + if (ruleFile == null || ruleFile.rules == null || ruleFile.rules.isEmpty()) { + var message = "Secret scanning rules YAML at '" + path + "' contained no rules"; + logger.error(message); + throw new IllegalStateException(message); + } + + // Parse rules + List result = new ArrayList<>(); + for (RuleDefinition def : ruleFile.rules) { + if (def == null || def.id == null || def.regex == null) { + continue; + } + SecretRule.Builder builder = new SecretRule.Builder() + .id(def.id) + .description(def.description != null ? def.description : "") + .regex(def.regex); + if (def.entropy != null) { + builder.entropy(def.entropy); + } + if (def.keywords != null && !def.keywords.isEmpty()) { + builder.keywords(def.keywords.toArray(new String[0])); + } + if (def.allowlists != null && !def.allowlists.isEmpty()) { + List agg = new ArrayList<>(); + for (Allowlist allow : def.allowlists) { + if (allow != null && allow.regexes != null) { + agg.addAll(allow.regexes); + } + } + if (!agg.isEmpty()) { + builder.allowlistRegexes(agg); + } + } + if (def.secretGroup != null) { + builder.secretGroup(def.secretGroup); + } + result.add(builder.build()); + } + + // Extract global allowlist if present + GlobalAllowlist globalAllowlist = ruleFile.allowlist; + + logger.debug("Loaded {} rules from YAML {}", result.size(), path); + if (globalAllowlist != null) { + int pathCount = globalAllowlist.paths != null ? globalAllowlist.paths.size() : 0; + int regexCount = globalAllowlist.regexes != null ? globalAllowlist.regexes.size() : 0; + int stopwordCount = globalAllowlist.stopwords != null ? globalAllowlist.stopwords.size() : 0; + int extensionCount = globalAllowlist.fileExtensions != null ? globalAllowlist.fileExtensions.size() : 0; + logger.debug("Loaded global allowlist from YAML: {} paths, {} regexes, {} stopwords, {} file extensions", + pathCount, regexCount, stopwordCount, extensionCount); + } + + return new RuleFileData(result, globalAllowlist); + } catch (IOException e) { + var message = "Failed to load secret scanning rules from YAML '" + path + "'"; + logger.error(message, e); + throw new IllegalStateException(message, e); + } + } + + private @Nullable InputStream openStream(@NotNull String path) throws IOException { + if (path.startsWith("classpath:")) { + String cp = path.substring("classpath:".length()); + ClassPathResource resource = new ClassPathResource(cp); + if (!resource.exists()) { + return null; + } + return resource.getInputStream(); + } + File f = new File(path); + if (!f.exists() || !f.isFile()) { + return null; + } + return new FileInputStream(f); + } + + /** + * DTO for YAML root structure. + * + * allowlist: + * paths: + * - "\.test\." + * regexes: + * - "example" + * stopwords: + * - "placeholder" + * file-extensions: + * - ".png" + * rules: + * - id: github-pat + * description: GitHub Personal Access Token + * regex: "ghp_[0-9a-zA-Z]{36}" + * keywords: ["github", "token"] + */ + public static class RuleFile { + /** List of secret detection rules */ + public List rules; + + /** Global allowlist configuration that applies to all rules */ + public GlobalAllowlist allowlist; + } + + /** + * DTO for individual rule definitions in YAML. + * + * Each rule defines how to detect a specific type of secret (API key, token, etc.). + */ + public static class RuleDefinition { + /** Unique rule identifier (required) */ + public String id; + + /** Human-readable description of what this rule detects */ + public String description; + + /** Regex pattern to match secrets (required, compiled as case-insensitive) */ + public String regex; + + /** Minimum Shannon entropy threshold (0.0-8.0). Matches below this are filtered out. */ + public Double entropy; + + /** Keywords that must appear in a line before applying regex (performance optimization) */ + public List keywords; + + /** Rule-specific allowlist patterns to exclude known safe matches */ + public List allowlists; + + /** Capture group index to extract the secret value (default: 1 if available, else 0) */ + public Integer secretGroup; + } + + /** + * DTO for rule-specific allowlist configuration. + * + * Defines patterns that, when matched, cause a potential secret match to be excluded + * as a known safe value. + */ + public static class Allowlist { + /** Regex patterns to exclude from this rule's matches (case-insensitive) */ + public List regexes; + } + + /** + * DTO for global allowlist configuration. + * + * Global allowlists apply to all rules and define: + * + * paths: Regex patterns for file paths to exclude from scanning + * regexes: Regex patterns for content to exclude as known safe values + * stopwords: Exact strings to exclude (e.g., "example", "placeholder", "test") + * file-extensions: File extensions to exclude from scanning (e.g., ".png", ".jpg") + * + * These are loaded from the YAML files and merged with configuration from application.yml. + */ + public static class GlobalAllowlist { + /** Regex patterns for file paths to exclude (e.g., "node_modules/", "\.test\.") */ + public List paths; + + /** Regex patterns for content to exclude as known safe (e.g., "^example$", "test.*") */ + public List regexes; + + /** Exact strings to exclude (case-insensitive, e.g., "placeholder", "changeme") */ + public List stopwords; + + /** File extensions to skip scanning (e.g., ".png", ".jpg", ".pdf") */ + @JsonProperty("file-extensions") + public List fileExtensions; + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanResult.java b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanResult.java new file mode 100644 index 000000000..bda46ef03 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanResult.java @@ -0,0 +1,78 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import jakarta.validation.constraints.NotNull; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Represents the overall result of a secret scan. + * Immutable so callers cannot mutate findings after creation. + */ +public final class SecretScanResult { + + private final boolean secretsFound; + private final @NotNull List findings; + + private SecretScanResult(boolean secretsFound, @NotNull List findings) { + this.secretsFound = secretsFound; + this.findings = Collections.unmodifiableList(new ArrayList<>(findings)); + } + + /** + * Factory for a result when secrets are found. + */ + public static SecretScanResult secretsFound(@NotNull List findings) { + if (findings.isEmpty()) { + throw new IllegalArgumentException("Cannot create secretsFound result with empty findings"); + } + return new SecretScanResult(true, findings); + } + + /** + * Factory for a result when no secrets are present. + */ + public static SecretScanResult noSecretsFound() { + return new SecretScanResult(false, List.of()); + } + + /** + * Factory for a result when scanning was skipped. + */ + public static SecretScanResult skipped() { + return new SecretScanResult(false, List.of()); + } + + public boolean isSecretsFound() { + return secretsFound; + } + + public @NotNull List getFindings() { + return findings; + } + + public @NotNull String getSummaryMessage() { + if (!secretsFound) { + return "No secrets detected"; + } + + return String.format( + "Found %d potential secret%s in extension package", + findings.size(), + findings.size() == 1 ? "" : "s" + ); + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanner.java b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanner.java new file mode 100644 index 000000000..f49908398 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanner.java @@ -0,0 +1,402 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.openvsx.util.ArchiveUtil; +import jakarta.validation.constraints.NotNull; +import javax.annotation.Nullable; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import com.google.re2j.Pattern; + +/** + * Scans individual files for secrets using keyword routing and regex validation. + */ +class SecretScanner { + + @FunctionalInterface + interface FindingRecorder { + boolean record(@NotNull List findings, @NotNull AtomicInteger count, @NotNull SecretFinding finding); + } + + private static final Logger logger = LoggerFactory.getLogger(SecretScanner.class); + private final AhoCorasick keywordMatcher; + private final Map> keywordToRules; + private final List rules; + private final List globalAllowlistPatterns; + private final List globalExcludedPathPatterns; + private final AhoCorasick globalStopwordMatcher; + private final AhoCorasick globalExcludedExtensionMatcher; + private final AhoCorasick inlineSuppressionMatcher; + private final EntropyCalculator entropyCalculator; + private final long maxFileSizeBytes; + private final int maxLineLength; + private final int timeoutCheckEveryNLines; + private final int longLineNoSpaceThreshold; + private final int keywordContextChars; + private final int logAllowlistedPreviewLength; + + SecretScanner(@NotNull AhoCorasick keywordMatcher, + @NotNull Map> keywordToRules, + @NotNull List rules, + @Nullable List allowlistPatterns, + @Nullable List excludedPathPatterns, + @Nullable AhoCorasick stopwordMatcher, + @Nullable AhoCorasick excludedExtensionMatcher, + @Nullable AhoCorasick inlineSuppressionMatcher, + @NotNull EntropyCalculator entropyCalculator, + long maxFileSizeBytes, + int maxLineLength, + int timeoutCheckEveryNLines, + int longLineNoSpaceThreshold, + int keywordContextChars, + int logAllowlistedPreviewLength) { + this.keywordMatcher = keywordMatcher; + this.keywordToRules = keywordToRules; + this.rules = rules; + this.globalAllowlistPatterns = allowlistPatterns != null ? allowlistPatterns : List.of(); + this.globalExcludedPathPatterns = excludedPathPatterns != null ? excludedPathPatterns : List.of(); + this.globalStopwordMatcher = stopwordMatcher; + this.globalExcludedExtensionMatcher = excludedExtensionMatcher; + this.inlineSuppressionMatcher = inlineSuppressionMatcher; + this.entropyCalculator = entropyCalculator; + this.maxFileSizeBytes = maxFileSizeBytes; + this.maxLineLength = maxLineLength; + this.timeoutCheckEveryNLines = timeoutCheckEveryNLines; + this.longLineNoSpaceThreshold = longLineNoSpaceThreshold; + this.keywordContextChars = keywordContextChars; + this.logAllowlistedPreviewLength = logAllowlistedPreviewLength; + } + + boolean scanFile(@NotNull ZipFile zipFile, + @NotNull ZipEntry entry, + @NotNull List findings, + long startTime, + long timeoutMillis, + @NotNull AtomicInteger findingsCount, + @NotNull FindingRecorder recorder) throws IOException { + String filePath = entry.getName(); + + if (Thread.currentThread().isInterrupted()) { + return false; + } + + if (!ArchiveUtil.isSafePath(filePath)) { + return false; + } + + if (entry.getSize() < 0 && entry.getCompressedSize() < 0) { + return false; + } + + if (entry.getSize() > maxFileSizeBytes) { + return false; + } + + // Check if the file type is excluded + if (isExcludedFileType(filePath)) { + return false; + } + + // Check if the file is excluded by any of the global excluded patterns + if (shouldExcludeFile(filePath)) { + return false; + } + + // Read the file line by line with a hard byte limit + try (InputStream zipStream = zipFile.getInputStream(entry); + // Use a limited stream since the entry header may not correctly reflect the file size + InputStream limitedStream = new SizeLimitInputStream(zipStream, maxFileSizeBytes); + BufferedReader reader = new BufferedReader( + new InputStreamReader(limitedStream, StandardCharsets.UTF_8))) { + + int lineNumber = 0; + String line; + + while ((line = reader.readLine()) != null) { + if (lineNumber % timeoutCheckEveryNLines == 0 && System.currentTimeMillis() - startTime > timeoutMillis) { + throw new SecretScanningTimeoutException("Secret scanning timed out during file: " + filePath); + } + + lineNumber++; + + // Check if the line is too long + if (line.length() > maxLineLength) { + continue; + } + + // Check if the line is likely to be a minified/bundled file + if (line.length() > longLineNoSpaceThreshold && !StringUtils.containsWhitespace(line)) { + continue; + } + + // Check if the line is suppressed by the publisher + if (hasInlineSuppression(line)) { + continue; + } + + scanLineWithKeywordMatching(line, line.toLowerCase(), filePath, lineNumber, findings, findingsCount, recorder); + } + } catch (IOException e) { + logger.warn("Error reading file {}: {}", filePath, e.getMessage()); + throw e; + } + + return true; + } + + /** + * Scan the line with keyword matching. + * This is a performance optimization to skip the regex matching for lines that don't contain any keywords. + */ + private void scanLineWithKeywordMatching(@NotNull String line, + @NotNull String lowerLine, + @NotNull String filePath, + int lineNumber, + @NotNull List findings, + @NotNull AtomicInteger findingsCount, + @NotNull FindingRecorder recorder) { + if (Thread.currentThread().isInterrupted()) { + return; + } + + List keywordMatches = keywordMatcher.search(lowerLine); + + Set processedRules = new HashSet<>(); + + for (AhoCorasick.Match match : keywordMatches) { + String keyword = match.getKeyword(); + List relevantRules = keywordToRules.get(keyword); + + if (relevantRules == null) { + continue; + } + + for (SecretRule rule : relevantRules) { + if (processedRules.contains(rule)) { + continue; + } + processedRules.add(rule); + + int chunkStart = Math.max(0, match.getStartPos() - keywordContextChars); + int chunkEnd = Math.min(line.length(), match.getEndPos() + keywordContextChars); + String chunk = line.substring(chunkStart, chunkEnd); + + scanLineWithRule(rule, chunk, filePath, lineNumber, findings, findingsCount, recorder); + } + } + + for (SecretRule rule : rules) { + if (rule.getKeywords().isEmpty() && !processedRules.contains(rule)) { + scanLineWithRule(rule, line, filePath, lineNumber, findings, findingsCount, recorder); + } + } + } + + /** + * Scan the line with the given rule's regex pattern + */ + private void scanLineWithRule(@NotNull SecretRule rule, + @NotNull String chunk, + @NotNull String filePath, + int lineNumber, + @NotNull List findings, + @NotNull AtomicInteger findingsCount, + @NotNull FindingRecorder recorder) { + if (Thread.currentThread().isInterrupted()) { + return; + } + + var matcher = rule.getPattern().matcher(chunk); + + while (matcher.find()) { + if (Thread.currentThread().isInterrupted()) { + return; + } + + // Extract the secret value from the matched group + int defaultGroup = matcher.groupCount() > 0 ? 1 : 0; + int groupIndex = defaultGroup; + Integer configuredGroup = rule.getSecretGroup(); + + if (configuredGroup != null) { + if (configuredGroup >= 0 && configuredGroup <= matcher.groupCount()) { + groupIndex = configuredGroup; + } + } + + String secretValue = matcher.group(groupIndex); + + if (secretValue == null) { + continue; + } + + // Check if the secret value is allowlisted by any of the rule's allowlist patterns + if (!rule.getAllowlistPatterns().isEmpty()) { + boolean allowlisted = false; + for (Pattern allow : rule.getAllowlistPatterns()) { + if (allow.matcher(secretValue).find()) { + allowlisted = true; + break; + } + } + if (allowlisted) { + logger.debug("Skipping rule-allowlisted content in {}:{}: {}", + filePath, lineNumber, secretValue.substring(0, Math.min(logAllowlistedPreviewLength, secretValue.length()))); + continue; + } + } + + // Check if the secret value is allowlisted by any of the global allowlist patterns + if (isAllowlistedContent(secretValue)) { + logger.debug("Skipping globally allowlisted content in {}:{}: {}", + filePath, lineNumber, secretValue.substring(0, Math.min(logAllowlistedPreviewLength, secretValue.length()))); + continue; + } + + Double requiredEntropy = rule.getEntropy(); + double entropy = 0.0; + + // Check if the secret value is below the rule's required entropy threshold + if (requiredEntropy != null) { + entropy = entropyCalculator.calculate(secretValue); + if (entropy < requiredEntropy) { + continue; + } + } else { + entropy = entropyCalculator.calculate(secretValue); + } + + logger.debug("Secret detected in {}:{} (rule: {}, entropy: {})", + filePath, lineNumber, rule.getId(), entropy); + + SecretFinding finding = new SecretFinding(filePath, lineNumber, entropy, secretValue, rule.getId()); + if (!recorder.record(findings, findingsCount, finding)) { + return; + } + } + } + + /** + * Check if the line has any inline suppressions + */ + private boolean hasInlineSuppression(@NotNull String line) { + if (inlineSuppressionMatcher == null) { + return false; + } + + return !inlineSuppressionMatcher.search(line.toLowerCase()).isEmpty(); + } + + /** + * Check if the file path is excluded by any of the global excluded path patterns + */ + private boolean shouldExcludeFile(@NotNull String filePath) { + for (Pattern pattern : globalExcludedPathPatterns) { + if (pattern.matcher(filePath).find()) { + return true; + } + } + + return false; + } + + /** + * Check if the file type is excluded by any of the global excluded file extensions + */ + private boolean isExcludedFileType(@NotNull String filePath) { + String lowerPath = filePath.toLowerCase(); + + if (globalExcludedExtensionMatcher != null) { + for (AhoCorasick.Match match : globalExcludedExtensionMatcher.search(lowerPath)) { + if (match.getEndPos() == lowerPath.length()) { + return true; + } + } + } + + return false; + } + + /** + * Check if the secret value is allowlisted by any of the global allowlist patterns + */ + private boolean isAllowlistedContent(@NotNull String secretValue) { + // Stopwords are exact strings that should never be flagged as secrets + if (globalStopwordMatcher != null && !globalStopwordMatcher.search(secretValue.toLowerCase()).isEmpty()) { + return true; + } + + // Allowlist patterns are regexes that may be case-sensitive, use original secretValue + for (Pattern allowPattern : globalAllowlistPatterns) { + if (allowPattern.matcher(secretValue).find()) { + return true; + } + } + + return false; + } + + /** + * InputStream wrapper that enforces a hard byte limit to prevent OOM from misleading entry headers. + */ + private static class SizeLimitInputStream extends java.io.FilterInputStream { + private final long maxBytes; + private long bytesRead = 0; + + protected SizeLimitInputStream(InputStream in, long maxBytes) { + super(in); + this.maxBytes = maxBytes; + } + + @Override + public int read() throws IOException { + int b = super.read(); + if (b != -1) { + checkLimit(1); + } + return b; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int n = super.read(b, off, len); + if (n != -1) { + checkLimit(n); + } + return n; + } + + private void checkLimit(long n) throws IOException { + bytesRead += n; + if (bytesRead > maxBytes) { + throw new IOException("File size exceeds limit of " + maxBytes + " bytes"); + } + } + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/SecretScannerFactory.java b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScannerFactory.java new file mode 100644 index 000000000..762af7da9 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScannerFactory.java @@ -0,0 +1,297 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import com.google.re2j.Pattern; +import jakarta.annotation.PostConstruct; +import jakarta.validation.constraints.NotNull; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.DependsOn; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Builds and wires the secret-scanning primitives once at startup. + * + * The factory only initializes if rule paths are configured (via {@code rules-path}) + * or auto-generation is enabled (via {@code auto-generate-rules}). + * If neither is configured, initialization is skipped, allowing the application + * to start without requiring secret scanning infrastructure. + * + * This component depends on {@link GitleaksRulesGenerator} to ensure rules + * are generated (if configured) before we try to load them. + */ +@Component +@DependsOn("gitleaksRulesGenerator") +public class SecretScannerFactory { + + private static final Logger logger = LoggerFactory.getLogger(SecretScannerFactory.class); + + private final SecretRuleLoader ruleLoader; + private final SecretScanningConfiguration config; + private final GitleaksRulesGenerator generator; + + private List rules = List.of(); + private Map> keywordToRules = Map.of(); + private AhoCorasick keywordMatcher = new AhoCorasick(); + private SecretScanner scanner; + + public SecretScannerFactory( + @NotNull SecretRuleLoader ruleLoader, + @NotNull SecretScanningConfiguration config, + @NotNull GitleaksRulesGenerator generator) { + this.ruleLoader = ruleLoader; + this.config = config; + this.generator = generator; + } + + @PostConstruct + public void initialize() { + // Build rules path list, prepending the generated file if auto-generation is enabled + List rulePaths = buildRulePaths(); + + // Skip initialization if there are no rule paths to load + // This happens when secret scanning is not configured at all + if (rulePaths.isEmpty()) { + logger.info("No secret scanning rules configured; skipping scanner initialization"); + return; + } + + // Load all rules from the rule paths + SecretRuleLoader.LoadedRules loaded = ruleLoader.loadAll(rulePaths); + List loadedRules = loaded.getRules(); + SecretRuleLoader.GlobalAllowlist globalAllowlist = loaded.getGlobalAllowlist(); + + // Build the keyword index for efficient keyword matching based on all loaded rules + Set allKeywords = new HashSet<>(); + Map> keywordIndex = buildKeywordIndex(loadedRules, allKeywords); + AhoCorasick builtKeywordMatcher = buildMatcher(allKeywords); + + // Build the global excluded path patterns for path matching based on the global allowlist + List globalExcludedPathPatterns = getGlobalExcludedPathPatterns(globalAllowlist, config); + List globalAllowlistPatterns = getGlobalAllowlistPatterns(globalAllowlist, config); + List globalStopwords = getGlobalStopwords(globalAllowlist, config); + List globalExcludedExtensions = getGlobalExcludedExtensions(globalAllowlist, config); + + // Build the matchers for global stopwords, excluded extensions, and inline suppressions + AhoCorasick globalStopwordMatcher = buildMatcher(globalStopwords); + AhoCorasick globalExcludedExtensionMatcher = buildMatcher(globalExcludedExtensions); + + List inlineSuppressionsLower = getInlineSuppressions(config); + AhoCorasick inlineSuppressionMatcher = buildMatcher(inlineSuppressionsLower); + + // Build the entropy calculator for entropy calculation + EntropyCalculator entropyCalculator = new EntropyCalculator(); + + this.rules = List.copyOf(loadedRules); + this.keywordToRules = keywordIndex; + this.keywordMatcher = builtKeywordMatcher != null ? builtKeywordMatcher : new AhoCorasick(); + this.scanner = new SecretScanner( + this.keywordMatcher, + this.keywordToRules, + this.rules, + globalAllowlistPatterns, + globalExcludedPathPatterns, + globalStopwordMatcher, + globalExcludedExtensionMatcher, + inlineSuppressionMatcher, + entropyCalculator, + config.getMaxFileSizeBytes(), + config.getMaxLineLength(), + config.getTimeoutCheckEveryNLines(), + config.getLongLineNoSpaceThreshold(), + config.getKeywordContextChars(), + config.getLogAllowlistedPreviewLength() + ); + + logger.info("Secret scanning initialized: {} rules loaded, {} unique keywords indexed", + this.rules.size(), this.keywordToRules.size()); + } + + public @Nullable SecretScanner getScanner() { + return scanner; + } + + public @NotNull List getRules() { + return rules; + } + + public @NotNull Map> getKeywordToRules() { + return keywordToRules; + } + + public @NotNull AhoCorasick getKeywordMatcher() { + return keywordMatcher; + } + + private Map> buildKeywordIndex(@NotNull List sourceRules, @NotNull Set allKeywords) { + Map> index = new HashMap<>(); + + for (SecretRule rule : sourceRules) { + for (String keyword : rule.getKeywords()) { + allKeywords.add(keyword); + index.computeIfAbsent(keyword, k -> new ArrayList<>()).add(rule); + } + } + + logger.debug("Built keyword index with {} keywords from {} rules", allKeywords.size(), sourceRules.size()); + return index; + } + + /** + * Build the Aho-Corasick matcher for the given keywords + */ + private AhoCorasick buildMatcher(java.util.Collection keywords) { + if (keywords == null || keywords.isEmpty()) { + return null; + } + + Set normalized = new HashSet<>(); + for (String keyword : keywords) { + if (keyword == null) { + continue; + } + String trimmed = keyword.trim(); + if (trimmed.isEmpty()) { + continue; + } + normalized.add(trimmed.toLowerCase()); + } + + if (normalized.isEmpty()) { + return null; + } + + AhoCorasick matcher = new AhoCorasick(); + matcher.build(normalized); + return matcher; + } + + /** + * Get global excluded file extensions from YAML allowlist or fall back to config. + * Combines extensions from the global allowlist in the YAML with extensions from config. + */ + private List getGlobalExcludedExtensions( + @Nullable SecretRuleLoader.GlobalAllowlist globalAllowlist, + @NotNull SecretScanningConfiguration config) { + List result = new ArrayList<>(); + + if (globalAllowlist != null && globalAllowlist.fileExtensions != null) { + result.addAll(globalAllowlist.fileExtensions.stream() + .map(String::toLowerCase) + .toList()); + } + + return result; + } + + /** + * Get global excluded path patterns from YAML allowlist or fall back to config. + * Paths are compiled as regex patterns for flexible matching. + * Combines paths from the global allowlist in the YAML with paths from config. + */ + private List getGlobalExcludedPathPatterns( + @Nullable SecretRuleLoader.GlobalAllowlist globalAllowlist, + @NotNull SecretScanningConfiguration config) { + List result = new ArrayList<>(); + + if (globalAllowlist != null && globalAllowlist.paths != null) { + result.addAll(globalAllowlist.paths.stream() + .map(path -> Pattern.compile(path, Pattern.CASE_INSENSITIVE)) + .toList()); + } + + return result; + } + + /** + * Get global allowlist patterns from YAML or fall back to config. + * Uses regex patterns from the global allowlist in the YAML, falling back to config. + */ + private List getGlobalAllowlistPatterns( + @Nullable SecretRuleLoader.GlobalAllowlist globalAllowlist, + @NotNull SecretScanningConfiguration config) { + List result = new ArrayList<>(); + + if (globalAllowlist != null && globalAllowlist.regexes != null && !globalAllowlist.regexes.isEmpty()) { + result.addAll(globalAllowlist.regexes.stream() + .map(regex -> Pattern.compile(regex, Pattern.CASE_INSENSITIVE)) + .toList()); + } + + return result; + } + + /** + * Get global stopwords from YAML or fall back to config. + * Uses stopwords from the global allowlist in the YAML, falling back to config. + */ + private List getGlobalStopwords( + @Nullable SecretRuleLoader.GlobalAllowlist globalAllowlist, + @NotNull SecretScanningConfiguration config) { + List result = new ArrayList<>(); + + if (globalAllowlist != null && globalAllowlist.stopwords != null) { + result.addAll(globalAllowlist.stopwords.stream() + .map(String::toLowerCase) + .toList()); + } + + return result; + } + + /** + * Get inline suppression markers from config. + * Returns lowercase versions of the suppression markers for case-insensitive matching. + */ + private List getInlineSuppressions(@NotNull SecretScanningConfiguration config) { + return config.getInlineSuppressions().stream() + .map(String::toLowerCase) + .toList(); + } + + /** + * Build the list of rule paths to load. + * If auto-generation is enabled and succeeded, prepend the generated file path. + * Otherwise, use the configured paths from application.yml. + */ + private List buildRulePaths() { + List paths = new ArrayList<>(); + + // If auto-generation is enabled and succeeded, use the generated file + if (config.isAutoGenerateRules()) { + String generatedPath = generator.getGeneratedRulesPath(); + if (generatedPath != null) { + logger.debug("Using auto-generated rules file: {}", generatedPath); + paths.add(generatedPath); + } else { + logger.warn("Auto-generation was enabled but no rules file was generated"); + } + } + + // Add configured paths (these may include custom rules or override gitleaks rules) + paths.addAll(config.getRulePaths()); + + return paths; + } +} + + diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningConfiguration.java b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningConfiguration.java new file mode 100644 index 000000000..e6620e867 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningConfiguration.java @@ -0,0 +1,337 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import com.google.re2j.Pattern; +import jakarta.annotation.PostConstruct; +import jakarta.validation.constraints.NotNull; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +import java.util.Arrays; +import java.util.List; + +/** + * Configuration for secret scanning in extension packages. + * + * This service scans VSIX files for potential secrets like API keys, tokens, and passwords + * before allowing publication. It uses regex patterns and entropy calculation to detect secrets. + */ +@Configuration +public class SecretScanningConfiguration { + + /** + * Enables or disables secret scanning for extension publishing. + * + * Property: {@code ovsx.secret-scanning.enabled} + * Default: {@code false} + */ + @Value("${ovsx.secret-scanning.enabled:false}") + private boolean enabled; + + /** + * Automatically generate secret scanning rules from gitleaks.toml at startup. + * + * When enabled, the application will download and convert the official gitleaks + * configuration to YAML format at startup if the rules file does not exist. + * + * Property: {@code ovsx.secret-scanning.auto-generate-rules} + * Default: {@code false} + */ + @Value("${ovsx.secret-scanning.auto-generate-rules:false}") + private boolean autoGenerateRules; + + /** + * Force regeneration of secret scanning rules even if they already exist. + * + * Only has an effect when {@code auto-generate-rules} is enabled. + * Use this to ensure rules are refreshed on every startup. + * + * Property: {@code ovsx.secret-scanning.force-regenerate-rules} + * Default: {@code false} + */ + @Value("${ovsx.secret-scanning.force-regenerate-rules:false}") + private boolean forceRegenerateRules; + + /** + * Full path where the auto-generated gitleaks rules will be written. + * + * This should be an absolute path to a writable location. The directory + * will be created automatically if it doesn't exist. + * + * Only used when {@code auto-generate-rules} is enabled. + * + * Property: {@code ovsx.secret-scanning.generated-rules-path} + * Default: empty + * Required when {@code auto-generate-rules} is true + * Example: {@code /app/data/secret-scanning-rules-gitleaks.yaml} + */ + @Value("${ovsx.secret-scanning.generated-rules-path:}") + private String generatedRulesPath; + + /** + * Maximum file size to scan in bytes. Files larger than this are skipped. + * + * Property: {@code ovsx.secret-scanning.max-file-size-bytes} + * Default: {@code 1048576} (1 MB) + */ + @Value("${ovsx.secret-scanning.max-file-size-bytes:1048576}") + private long maxFileSizeBytes; + + /** + * Maximum line length to process in characters. Files with longer lines are skipped. + * Very long lines (>10K) typically indicate minified/bundled code and may cause performance issues. + * + * Property: {@code ovsx.secret-scanning.max-line-length} + * Default: {@code 10000} + */ + @Value("${ovsx.secret-scanning.max-line-length:10000}") + private int maxLineLength; + + /** + * Comma-separated list of inline suppression markers that skip an entire line from scanning. + * + * Property: {@code ovsx.secret-scanning.inline-suppressions} + * Default: empty + * Example: {@code "secret-scanner:ignore,gitleaks:allow"} + */ + @Value("${ovsx.secret-scanning.inline-suppressions:}") + private String inlineSuppressionsString; + + + /** + * Timeout for scanning in seconds. If scanning takes longer, it will be aborted. + * + * Property: {@code ovsx.secret-scanning.timeout-seconds} + * Default: {@code 5} + */ + @Value("${ovsx.secret-scanning.timeout-seconds:5}") + private int timeoutSeconds; + + /** + * Maximum number of zip entries to inspect in an archive. + * + * Property: {@code ovsx.secret-scanning.max-entry-count} + * Default: {@code 5000} + */ + @Value("${ovsx.secret-scanning.max-entry-count:5000}") + private int maxEntryCount; + + /** + * Maximum total uncompressed bytes allowed across the archive. + * + * Property: {@code ovsx.secret-scanning.max-total-uncompressed-bytes} + * Default: {@code 104857600} (100 MB) + */ + @Value("${ovsx.secret-scanning.max-total-uncompressed-bytes:104857600}") + private long maxTotalUncompressedBytes; + + /** + * Maximum findings to collect before aborting to protect memory and UX. + * + * Property: {@code ovsx.secret-scanning.max-findings} + * Default: {@code 200} + */ + @Value("${ovsx.secret-scanning.max-findings:200}") + private int maxFindings; + + /** + * Comma-separated YAML rule file paths. Later entries override earlier by rule id. + * Supports {@code classpath:} prefix for classpath resources. + * + * Property: {@code ovsx.secret-scanning.rules-path} + * Default: empty + */ + @Value("${ovsx.secret-scanning.rules-path:}") + private String rulesPath; + + /** + * How often (in lines) to check for scan timeout while reading files. + * + * Property: {@code ovsx.secret-scanning.timeout-check-every-n-lines} + * Default: {@code 100} + */ + @Value("${ovsx.secret-scanning.timeout-check-every-n-lines:100}") + private int timeoutCheckEveryNLines; + + /** + * Lines longer than this threshold without spaces are skipped to avoid minified blobs. + * + * Property: {@code ovsx.secret-scanning.long-line-no-space-threshold} + * Default: {@code 1000} + */ + @Value("${ovsx.secret-scanning.long-line-no-space-threshold:1000}") + private int longLineNoSpaceThreshold; + + /** + * Characters of context around a keyword when applying regex matching. + * + * Property: {@code ovsx.secret-scanning.keyword-context-chars} + * Default: {@code 100} + */ + @Value("${ovsx.secret-scanning.keyword-context-chars:100}") + private int keywordContextChars; + + /** + * Characters to include when logging secret preview values (for debugging allowlisted secrets). + * + * Property: {@code ovsx.secret-scanning.log-secret-preview-chars} + * Default: {@code 10} + */ + @Value("${ovsx.secret-scanning.log-allowlisted-value-preview-length:10}") + private int logAllowlistedPreviewLength; + + public boolean isEnabled() { + return enabled; + } + + public long getMaxFileSizeBytes() { + return maxFileSizeBytes; + } + + public int getMaxLineLength() { + return maxLineLength; + } + + /** + * Inline suppression markers that skip an entire line from scanning. + */ + public @NotNull List getInlineSuppressions() { + if (inlineSuppressionsString == null || inlineSuppressionsString.trim().isEmpty()) { + return List.of(); + } + return Arrays.stream(inlineSuppressionsString.split(",")) + .map(String::trim) + .map(String::toLowerCase) + .filter(s -> !s.isEmpty()) + .toList(); + } + + public int getTimeoutSeconds() { + return timeoutSeconds; + } + + public int getMaxEntryCount() { + return maxEntryCount; + } + + public long getMaxTotalUncompressedBytes() { + return maxTotalUncompressedBytes; + } + + public int getMaxFindings() { + return maxFindings; + } + + /** + * Get YAML rule paths, supporting comma-separated inputs. + * Empty input yields an empty list so callers can fail fast. + */ + public @NotNull List getRulePaths() { + if (rulesPath == null || rulesPath.trim().isEmpty()) { + return List.of(); + } + return Arrays.stream(rulesPath.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + } + + public int getTimeoutCheckEveryNLines() { + return timeoutCheckEveryNLines; + } + + public int getLongLineNoSpaceThreshold() { + return longLineNoSpaceThreshold; + } + + public int getKeywordContextChars() { + return keywordContextChars; + } + + public int getLogAllowlistedPreviewLength() { + return logAllowlistedPreviewLength; + } + + public boolean isAutoGenerateRules() { + return autoGenerateRules; + } + + public boolean isForceRegenerateRules() { + return forceRegenerateRules; + } + + public String getGeneratedRulesPath() { + return generatedRulesPath; + } + + @PostConstruct + public void validate() { + // Max file size should be positive + if (maxFileSizeBytes <= 0) { + throw new IllegalArgumentException( + "ovsx.secret-scanning.max-file-size-bytes must be positive, got: " + maxFileSizeBytes); + } + + // Max line length should be positive + if (maxLineLength <= 0) { + throw new IllegalArgumentException( + "ovsx.secret-scanning.max-line-length must be positive, got: " + maxLineLength); + } + + // Timeout should be positive + if (timeoutSeconds <= 0) { + throw new IllegalArgumentException( + "ovsx.secret-scanning.timeout-seconds must be positive, got: " + timeoutSeconds); + } + + // Entry cap must be positive + if (maxEntryCount <= 0) { + throw new IllegalArgumentException( + "ovsx.secret-scanning.max-entry-count must be positive, got: " + maxEntryCount); + } + + // Total size cap must be positive + if (maxTotalUncompressedBytes <= 0) { + throw new IllegalArgumentException( + "ovsx.secret-scanning.max-total-uncompressed-bytes must be positive, got: " + maxTotalUncompressedBytes); + } + + // Findings cap must be positive + if (maxFindings <= 0) { + throw new IllegalArgumentException( + "ovsx.secret-scanning.max-findings must be positive, got: " + maxFindings); + } + + if (timeoutCheckEveryNLines <= 0) { + throw new IllegalArgumentException( + "ovsx.secret-scanning.timeout-check-every-n-lines must be positive, got: " + timeoutCheckEveryNLines); + } + + if (longLineNoSpaceThreshold <= 0) { + throw new IllegalArgumentException( + "ovsx.secret-scanning.long-line-no-space-threshold must be positive, got: " + longLineNoSpaceThreshold); + } + + if (keywordContextChars < 0) { + throw new IllegalArgumentException( + "ovsx.secret-scanning.keyword-context-chars must be >= 0, got: " + keywordContextChars); + } + + if (logAllowlistedPreviewLength < 0) { + throw new IllegalArgumentException( + "ovsx.secret-scanning.log-secret-preview-chars must be >= 0, got: " + logAllowlistedPreviewLength); + } + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningService.java b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningService.java new file mode 100644 index 000000000..6ae7c60aa --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningService.java @@ -0,0 +1,218 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import org.eclipse.openvsx.util.TempFile; +import org.eclipse.openvsx.util.ArchiveUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.task.AsyncTaskExecutor; +import org.springframework.stereotype.Service; +import org.eclipse.openvsx.util.ErrorResultException; +import org.springframework.http.HttpStatus; +import jakarta.validation.constraints.NotNull; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.ZipEntry; +import java.util.zip.ZipException; +import java.util.zip.ZipFile; + +/** + * Service for scanning extension packages for potential secrets before publication. + * This service analyzes VSIX files to detect potential secrets like API keys, tokens, + * passwords, and other sensitive credentials that should not be published publicly. + * + * Uses Spring's default async executor for parallel file scanning within extension packages. + */ +@Service +public class SecretScanningService { + + private static final Logger logger = LoggerFactory.getLogger(SecretScanningService.class); + + private final SecretScanningConfiguration config; + private final SecretScanner fileContentScanner; + private final AsyncTaskExecutor taskExecutor; + + private final int maxEntryCount; + private final long maxTotalUncompressedBytes; + private final int maxFindings; + + /** + * Exception thrown when scan is cancelled due to finding limits or other constraints. + */ + static class ScanCancelledException extends RuntimeException { + ScanCancelledException(String message) { super(message); } + } + + /** + * Constructs a secret scanning service with the specified configuration and executor. + */ + public SecretScanningService( + SecretScanningConfiguration config, + SecretScannerFactory scannerFactory, + AsyncTaskExecutor taskExecutor) { + this.config = config; + this.taskExecutor = taskExecutor; + + // Cache configuration values that are reused during scanning + this.maxEntryCount = config.getMaxEntryCount(); + this.maxTotalUncompressedBytes = config.getMaxTotalUncompressedBytes(); + this.maxFindings = config.getMaxFindings(); + + this.fileContentScanner = scannerFactory.getScanner(); + } + + /** + * Scans an extension package for potential secrets. + * + * This method checks if secret scanning is enabled for the publishing flow. + * When disabled, extensions can still be published without secret detection. + * The scanner itself is always available for other use cases (e.g., retroactive scans). + */ + public SecretScanResult scanForSecrets(@NotNull TempFile extensionFile) { + if (!config.isEnabled()) { + logger.debug("Secret scanning is disabled for publishing"); + return SecretScanResult.skipped(); + } + + // Use thread-safe collection for parallel processing + List findings = Collections.synchronizedList(new ArrayList<>()); + AtomicInteger findingsCount = new AtomicInteger(0); // Cap findings to protect memory + + try (ZipFile zipFile = new ZipFile(extensionFile.getPath().toFile())) { + List entries = Collections.list(zipFile.entries()); + ArchiveUtil.enforceArchiveLimits(entries, maxEntryCount, maxTotalUncompressedBytes); + + AtomicInteger filesScanned = new AtomicInteger(0); + AtomicInteger filesSkipped = new AtomicInteger(0); + + long startTime = System.currentTimeMillis(); + long timeoutMillis = config.getTimeoutSeconds() * 1000L; + List> tasks = new ArrayList<>(entries.size()); + + try { + for (ZipEntry entry : entries) { + tasks.add(taskExecutor.submit(() -> { + if (System.currentTimeMillis() - startTime > timeoutMillis) { + throw new SecretScanningTimeoutException("Secret scanning timed out"); + } + + if (Thread.currentThread().isInterrupted()) { + return null; + } + + if (entry.isDirectory()) { + return null; + } + + String filePath = entry.getName(); + try { + boolean scanned = fileContentScanner.scanFile( + zipFile, + entry, + findings, + startTime, + timeoutMillis, + findingsCount, + this::recordFinding + ); + if (scanned) { + filesScanned.incrementAndGet(); + } else { + filesSkipped.incrementAndGet(); + } + } catch (SecretScanningTimeoutException | ScanCancelledException e) { + throw e; + } catch (Exception e) { + logger.warn("Failed to scan file {}: {}", filePath, e.getMessage()); + } + return null; + })); + } + + for (Future task : tasks) { + try { + task.get(); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } catch (ExecutionException ee) { + Throwable cause = ee.getCause(); + if (cause instanceof SecretScanningTimeoutException) { + throw (SecretScanningTimeoutException) ee.getCause(); + } else if (cause instanceof ScanCancelledException) { + break; + } + } + } + } finally { + // Ensure we cancel any remaining tasks if the loop exits early + for (Future f : tasks) { + f.cancel(true); + } + } + + logger.debug("Secret scan complete: {} files scanned, {} files skipped, {} findings", + filesScanned.get(), filesSkipped.get(), findings.size()); + + } catch (SecretScanningTimeoutException e) { + logger.error("Secret scanning timed out after {} seconds", config.getTimeoutSeconds()); + throw new ErrorResultException( + "Secret scanning timed out after " + config.getTimeoutSeconds() + + " seconds. Please reduce the file size or exclude large files.", + HttpStatus.REQUEST_TIMEOUT); + } catch (ZipException e) { + logger.error("Failed to open extension file as zip: {}", e.getMessage()); + throw new ErrorResultException("Failed to scan extension file: invalid zip format", HttpStatus.BAD_REQUEST); + } catch (IOException e) { + logger.error("Failed to scan extension file: {}", e.getMessage()); + throw new ErrorResultException("Failed to scan extension file", HttpStatus.BAD_REQUEST); + } + + if (findings.isEmpty()) { + return SecretScanResult.noSecretsFound(); + } else { + return SecretScanResult.secretsFound(findings); + } + } + + /** + * Record a finding while respecting the global cap. + */ + private boolean recordFinding(List findings, AtomicInteger findingsCount, + SecretFinding finding) { + int newCount = findingsCount.incrementAndGet(); + if (newCount > maxFindings) { + throw new ScanCancelledException("Max findings reached"); + } + findings.add(finding); + return true; + } +} + +/** + * Signals that a scan exceeded the configured timeout budget. + */ +class SecretScanningTimeoutException extends RuntimeException { + SecretScanningTimeoutException(String message) { + super(message); + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/util/ArchiveUtil.java b/server/src/main/java/org/eclipse/openvsx/util/ArchiveUtil.java index 97383d21e..086218411 100644 --- a/server/src/main/java/org/eclipse/openvsx/util/ArchiveUtil.java +++ b/server/src/main/java/org/eclipse/openvsx/util/ArchiveUtil.java @@ -11,6 +11,10 @@ import java.io.IOException; import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; @@ -49,4 +53,44 @@ public static TempFile readEntry(ZipFile archive, ZipEntry entry) throws IOExcep } return file; } + + /** + * Enforce coarse archive limits before reading content. + */ + public static void enforceArchiveLimits(List entries, + int maxEntryCount, + long maxTotalUncompressedBytes) { + if (entries.size() > maxEntryCount) { + throw new ErrorResultException("Archive contains too many entries (" + entries.size() + ")."); + } + + long declaredTotal = 0; + for (ZipEntry entry : entries) { + long size = entry.getSize(); + if (size > 0) { + declaredTotal += size; + if (declaredTotal > maxTotalUncompressedBytes) { + throw new ErrorResultException("Uncompressed archive size exceeds limit of " + maxTotalUncompressedBytes + " bytes."); + } + } + } + } + + /** + * Reject zip entries that attempt path traversal or absolute paths. + */ + public static boolean isSafePath(String filePath) { + try { + if (filePath.startsWith("/") || filePath.startsWith("\\")) { + return false; + } + + Path normalized = Paths.get(filePath).normalize(); + + return !(normalized.isAbsolute() || normalized.startsWith("..") || normalized.toString().startsWith("..")); + } catch (InvalidPathException e) { + return false; + } + } + } \ No newline at end of file diff --git a/server/src/main/resources/scanning/secret-scanning-custom-rules.yaml b/server/src/main/resources/scanning/secret-scanning-custom-rules.yaml new file mode 100644 index 000000000..49787432b --- /dev/null +++ b/server/src/main/resources/scanning/secret-scanning-custom-rules.yaml @@ -0,0 +1,92 @@ +allowlist: + paths: + regexes: + - "bundle\\.js" + - "chunk\\.js" + - "webpack" + - "rollup" + - "-bom_versions\\.xml" + - "\\.min\\.js" + - "\\.min\\.css" + - "\\.test\\." + - "\\.spec\\." + stopwords: + - "0123456789" + - "xxxxxx" + - "changeme" + - "default" + - "dummy" + - "example" + - "fake" + - "insert" + - "username" + - "user" + - "password" + - "pass" + - "localhost" + - "placeholder" + - "redacted" + - "replace" + - "root" + - "sample" + - "secret" + - "some" + - "test" + - "your" + - "true" + - "false" + - "null" + - "..." + file-extensions: + - ".png" + - ".jpg" + - ".jpeg" + - ".md" + - ".markdown" + - ".gif" + - ".ico" + - ".svg" + - ".webp" + - ".woff" + - ".woff2" + - ".ttf" + - ".eot" + - ".otf" + - ".mp3" + - ".wav" + - ".ogg" + - ".mp4" + - ".webm" + - ".avi" + - ".zip" + - ".tar" + - ".gz" + - ".7z" + - ".rar" + - ".pdf" + - ".doc" + - ".docx" + - ".exe" + - ".dll" + - ".so" + - ".dylib" + - ".class" + - ".jar" + - ".war" + - ".pyc" + - ".pyo" + - ".o" + - ".a" + - ".mjs" + - ".csv" + - ".tsv" + - ".map" + +rules: + - id: openvsx-pat-prefixed + description: "Detected an OpenVSX Personal Access Token with explicit prefix, risking unauthorized extension publishing." + regex: "\\b((?:ovsxat|ovsxp)[_-][a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})\\b" + entropy: 2.5 + keywords: + - ovsxat_ + - ovsxp_ \ No newline at end of file diff --git a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java index 230583327..f26a27677 100644 --- a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java @@ -27,6 +27,7 @@ import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; import org.eclipse.openvsx.publish.PublishExtensionVersionService; import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.scanning.SecretScanningService; import org.eclipse.openvsx.search.*; import org.eclipse.openvsx.security.OAuth2AttributesConfig; import org.eclipse.openvsx.security.OAuth2UserServices; @@ -87,7 +88,8 @@ ClientRegistrationRepository.class, UpstreamRegistryService.class, GoogleCloudStorageService.class, AzureBlobStorageService.class, AwsStorageService.class, VSCodeIdService.class, DownloadCountService.class, CacheService.class, EclipseService.class, PublishExtensionVersionService.class, SimpleMeterRegistry.class, - JobRequestScheduler.class, ExtensionControlService.class, FileCacheDurationConfig.class, CdnServiceConfig.class + JobRequestScheduler.class, ExtensionControlService.class, FileCacheDurationConfig.class, CdnServiceConfig.class, + SecretScanningService.class }) class RegistryAPITest { @@ -2602,9 +2604,10 @@ ExtensionService extensionService( SearchUtilService search, CacheService cache, PublishExtensionVersionHandler publishHandler, - JobRequestScheduler scheduler + JobRequestScheduler scheduler, + SecretScanningService secretScanningService ) { - return new ExtensionService(entityManager, repositories, search, cache, publishHandler, scheduler); + return new ExtensionService(entityManager, repositories, search, cache, publishHandler, scheduler, secretScanningService); } @Bean diff --git a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java index 75ff8a7a3..2ce99be5f 100644 --- a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java @@ -22,6 +22,7 @@ import org.eclipse.openvsx.publish.ExtensionVersionIntegrityService; import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.scanning.SecretScanningService; import org.eclipse.openvsx.search.SearchUtilService; import org.eclipse.openvsx.search.SimilarityCheckService; import org.eclipse.openvsx.search.SimilarityConfig; @@ -71,7 +72,7 @@ @MockitoBean(types = { EclipseService.class, ClientRegistrationRepository.class, StorageUtilService.class, CacheService.class, ExtensionValidator.class, SimpleMeterRegistry.class, SearchUtilService.class, PublishExtensionVersionHandler.class, - JobRequestScheduler.class, VersionService.class, ExtensionVersionIntegrityService.class + JobRequestScheduler.class, VersionService.class, ExtensionVersionIntegrityService.class, SecretScanningService.class }) class UserAPITest { @@ -865,9 +866,10 @@ ExtensionService extensionService( SearchUtilService search, CacheService cache, PublishExtensionVersionHandler publishHandler, - JobRequestScheduler scheduler + JobRequestScheduler scheduler, + SecretScanningService secretScanningService ) { - return new ExtensionService(entityManager, repositories, search, cache, publishHandler, scheduler); + return new ExtensionService(entityManager, repositories, search, cache, publishHandler, scheduler, secretScanningService); } } } \ No newline at end of file diff --git a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java index d4e4d9d6a..ffeafd393 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java @@ -25,6 +25,7 @@ import org.eclipse.openvsx.publish.ExtensionVersionIntegrityService; import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.scanning.SecretScanningService; import org.eclipse.openvsx.search.SearchUtilService; import org.eclipse.openvsx.search.SimilarityCheckService; import org.eclipse.openvsx.search.SimilarityConfig; @@ -78,7 +79,8 @@ ClientRegistrationRepository.class, UpstreamRegistryService.class, GoogleCloudStorageService.class, AzureBlobStorageService.class, AwsStorageService.class, VSCodeIdService.class, DownloadCountService.class, CacheService.class, PublishExtensionVersionHandler.class, SearchUtilService.class, EclipseService.class, - SimpleMeterRegistry.class, FileCacheDurationConfig.class, MailService.class, CdnServiceConfig.class + SimpleMeterRegistry.class, FileCacheDurationConfig.class, MailService.class, CdnServiceConfig.class, + SecretScanningService.class }) class AdminAPITest { @@ -1455,9 +1457,10 @@ ExtensionService extensionService( SearchUtilService search, CacheService cache, PublishExtensionVersionHandler publishHandler, - JobRequestScheduler scheduler + JobRequestScheduler scheduler, + SecretScanningService secretScanningService ) { - return new ExtensionService(entityManager, repositories, search, cache, publishHandler, scheduler); + return new ExtensionService(entityManager, repositories, search, cache, publishHandler, scheduler, secretScanningService); } @Bean diff --git a/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java b/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java index e176cb931..29eb0b813 100644 --- a/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java +++ b/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java @@ -22,6 +22,7 @@ import org.eclipse.openvsx.entities.*; import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.scanning.SecretScanningService; import org.eclipse.openvsx.search.SearchUtilService; import org.eclipse.openvsx.storage.*; import org.eclipse.openvsx.storage.log.DownloadCountService; @@ -59,7 +60,7 @@ EntityManager.class, SearchUtilService.class, GoogleCloudStorageService.class, AzureBlobStorageService.class, AwsStorageService.class, VSCodeIdService.class, DownloadCountService.class, CacheService.class, UserService.class, PublishExtensionVersionHandler.class, SimpleMeterRegistry.class, FileCacheDurationConfig.class, - JobRequestScheduler.class, CdnServiceConfig.class + JobRequestScheduler.class, CdnServiceConfig.class, SecretScanningService.class }) class EclipseServiceTest { @@ -409,9 +410,10 @@ ExtensionService extensionService( SearchUtilService search, CacheService cache, PublishExtensionVersionHandler publishHandler, - JobRequestScheduler scheduler + JobRequestScheduler scheduler, + SecretScanningService secretScanningService ) { - return new ExtensionService(entityManager, repositories, search, cache, publishHandler, scheduler); + return new ExtensionService(entityManager, repositories, search, cache, publishHandler, scheduler, secretScanningService); } @Bean diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/GitleaksRulesGeneratorTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/GitleaksRulesGeneratorTest.java new file mode 100644 index 000000000..324a72994 --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/scanning/GitleaksRulesGeneratorTest.java @@ -0,0 +1,254 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@link GitleaksRulesGenerator}. + * + * These tests verify configuration validation, path resolution, and file generation logic + * without requiring network access or actual rule generation. + */ +class GitleaksRulesGeneratorTest { + + @TempDir + Path tempDir; + + @Test + void skipsGenerationWhenDisabled() { + SecretScanningConfiguration config = buildConfig(false, false, null); + GitleaksRulesGenerator generator = new GitleaksRulesGenerator(config); + + // Should not throw + generator.generateRulesIfNeeded(); + + // Should not set generated path + assertNull(generator.getGeneratedRulesPath(), "Path should not be set when disabled"); + } + + @Test + void skipsGenerationWhenAutoGenerateDisabled() { + SecretScanningConfiguration config = buildConfig(true, false, tempDir.resolve("rules.yaml").toString()); + GitleaksRulesGenerator generator = new GitleaksRulesGenerator(config); + + // Should not throw + generator.generateRulesIfNeeded(); + + // Should not set generated path + assertNull(generator.getGeneratedRulesPath(), "Path should not be set when auto-generate is disabled"); + } + + @Test + void failsWhenAutoGenerateEnabledButPathNotConfigured() { + SecretScanningConfiguration config = buildConfig(true, true, ""); // Empty path + GitleaksRulesGenerator generator = new GitleaksRulesGenerator(config); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + generator::generateRulesIfNeeded, + "Should throw when path is not configured" + ); + + // Check the cause since resolveOutputFile exceptions are wrapped + Throwable cause = exception.getCause(); + assertNotNull(cause, "Should have a cause"); + assertTrue(cause.getMessage().contains("generated-rules-path"), + "Error message should mention the missing configuration property"); + assertTrue(cause.getMessage().contains("not configured"), + "Error message should explain the problem"); + } + + @Test + void failsWhenAutoGenerateEnabledButPathIsNull() { + SecretScanningConfiguration config = buildConfig(true, true, null); // Null path + GitleaksRulesGenerator generator = new GitleaksRulesGenerator(config); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + generator::generateRulesIfNeeded, + "Should throw when path is null" + ); + + // Check the cause since resolveOutputFile exceptions are wrapped + Throwable cause = exception.getCause(); + assertNotNull(cause, "Should have a cause"); + assertTrue(cause.getMessage().contains("not configured")); + } + + @Test + void createsParentDirectoryIfNotExists() throws Exception { + Path outputFile = tempDir.resolve("subdir1/subdir2/rules.yaml"); + assertFalse(Files.exists(outputFile.getParent()), "Parent directory should not exist yet"); + + SecretScanningConfiguration config = buildConfig(true, true, outputFile.toString()); + GitleaksRulesGenerator generator = new GitleaksRulesGenerator(config); + + // Call resolveOutputFile via reflection + Method resolveMethod = GitleaksRulesGenerator.class.getDeclaredMethod("resolveOutputFile"); + resolveMethod.setAccessible(true); + File result = (File) resolveMethod.invoke(generator); + + assertNotNull(result); + assertTrue(Files.exists(result.getParentFile().toPath()), + "Parent directory should be created"); + assertEquals(outputFile.toString(), result.getAbsolutePath()); + } + + @Test + void failsWhenParentDirectoryCannotBeCreated() throws Exception { + // Create a directory structure where the parent doesn't exist yet + // and make it impossible to create by having a file in the way + Path subDir = tempDir.resolve("subdir"); + + // First create the subdir as a file (not a directory) + Files.writeString(subDir, "content"); + + // Now try to create a file under it - this should fail + Path impossiblePath = tempDir.resolve("subdir/content/rules.yaml"); + + SecretScanningConfiguration config = buildConfig(true, true, impossiblePath.toString()); + GitleaksRulesGenerator generator = new GitleaksRulesGenerator(config); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + generator::generateRulesIfNeeded + ); + + // The error will be wrapped, check the cause + Throwable cause = exception.getCause(); + assertNotNull(cause, "Should have a cause"); + assertTrue(cause.getMessage().contains("Cannot create directory") || + cause.getMessage().contains("Cannot write to directory"), + "Error message should mention directory issue: " + cause.getMessage()); + } + + @Test + void skipsGenerationWhenFileExistsAndNoForceRegenerate() throws Exception { + Path outputFile = tempDir.resolve("existing-rules.yaml"); + Files.writeString(outputFile, "existing content"); + long originalSize = Files.size(outputFile); + + SecretScanningConfiguration config = buildConfig(true, true, outputFile.toString()); + setField(config, "forceRegenerateRules", false); + GitleaksRulesGenerator generator = new GitleaksRulesGenerator(config); + + generator.generateRulesIfNeeded(); + + // File should not be modified + assertEquals(originalSize, Files.size(outputFile), "File should not be regenerated"); + assertEquals("existing content", Files.readString(outputFile), "Content should be unchanged"); + + // Path should still be set + assertEquals(outputFile.toAbsolutePath().toString(), generator.getGeneratedRulesPath()); + } + + @Test + void storesGeneratedPathForLaterRetrieval() throws Exception { + Path outputFile = tempDir.resolve("rules.yaml"); + Files.writeString(outputFile, "test"); // Pre-create to skip actual generation + + SecretScanningConfiguration config = buildConfig(true, true, outputFile.toString()); + setField(config, "forceRegenerateRules", false); + GitleaksRulesGenerator generator = new GitleaksRulesGenerator(config); + + assertNull(generator.getGeneratedRulesPath(), "Path should be null before generation"); + + generator.generateRulesIfNeeded(); + + assertNotNull(generator.getGeneratedRulesPath(), "Path should be set after generation"); + assertEquals(outputFile.toAbsolutePath().toString(), generator.getGeneratedRulesPath()); + } + + @Test + void resolveOutputFileReturnsNullWhenPathNotConfigured() throws Exception { + SecretScanningConfiguration config = buildConfig(true, true, ""); + GitleaksRulesGenerator generator = new GitleaksRulesGenerator(config); + + Method resolveMethod = GitleaksRulesGenerator.class.getDeclaredMethod("resolveOutputFile"); + resolveMethod.setAccessible(true); + + // Should throw when trying to resolve (wrapped in InvocationTargetException due to reflection) + Exception exception = assertThrows(Exception.class, () -> { + resolveMethod.invoke(generator); + }); + + // Unwrap the InvocationTargetException + Throwable cause = exception.getCause(); + assertTrue(cause instanceof IllegalStateException, + "Cause should be IllegalStateException"); + assertTrue(cause.getMessage().contains("not configured"), + "Error message should explain the configuration is missing"); + } + + @Test + void acceptsAbsolutePath() throws Exception { + Path outputFile = tempDir.resolve("absolute-path-rules.yaml").toAbsolutePath(); + + SecretScanningConfiguration config = buildConfig(true, true, outputFile.toString()); + GitleaksRulesGenerator generator = new GitleaksRulesGenerator(config); + + Method resolveMethod = GitleaksRulesGenerator.class.getDeclaredMethod("resolveOutputFile"); + resolveMethod.setAccessible(true); + File result = (File) resolveMethod.invoke(generator); + + assertEquals(outputFile.toString(), result.getAbsolutePath()); + } + + @Test + void acceptsRelativePath() throws Exception { + String relativePath = "relative/path/rules.yaml"; + + SecretScanningConfiguration config = buildConfig(true, true, relativePath); + GitleaksRulesGenerator generator = new GitleaksRulesGenerator(config); + + Method resolveMethod = GitleaksRulesGenerator.class.getDeclaredMethod("resolveOutputFile"); + resolveMethod.setAccessible(true); + File result = (File) resolveMethod.invoke(generator); + + assertNotNull(result); + assertTrue(result.getPath().contains("relative")); + } + + // Helper methods + + private SecretScanningConfiguration buildConfig(boolean enabled, boolean autoGenerate, String path) { + SecretScanningConfiguration config = new SecretScanningConfiguration(); + try { + setField(config, "enabled", enabled); + setField(config, "autoGenerateRules", autoGenerate); + setField(config, "generatedRulesPath", path); + setField(config, "forceRegenerateRules", false); + setField(config, "rulesPath", "classpath:test-rules.yaml"); + } catch (Exception e) { + throw new RuntimeException("Failed to configure test config", e); + } + return config; + } + + private void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} + diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/SecretRuleLoaderTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/SecretRuleLoaderTest.java new file mode 100644 index 000000000..35c982515 --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/scanning/SecretRuleLoaderTest.java @@ -0,0 +1,356 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Focused unit tests for {@link SecretRuleLoader}. + * These tests keep the loader behavior well-specified without touching Spring wiring. + */ +class SecretRuleLoaderTest { + + private final SecretRuleLoader loader = new SecretRuleLoader(); + + @Test + void loadAll_overridesRulesByIdAndPreservesOrder() { + // Use two YAML files where the second overrides rule-a and adds rule-b. + List rules = loader.loadAll(List.of( + "classpath:org/eclipse/openvsx/scanning/secret-rules-a.yaml", + "classpath:org/eclipse/openvsx/scanning/secret-rules-b.yaml" + )).getRules(); + + assertEquals(2, rules.size()); + + SecretRule first = rules.get(0); + SecretRule second = rules.get(1); + + assertEquals("rule-a", first.getId()); + assertEquals("override[0-9]+", first.getPattern().pattern()); + assertEquals(List.of("overridekey"), first.getKeywords()); + // Allowlist should be replaced when overriding the rule. + assertTrue(first.getAllowlistPatterns().isEmpty()); + + assertEquals("rule-b", second.getId()); + assertEquals(List.of("key"), second.getKeywords()); + } + + @Test + void loadAll_returnsEmptyWhenPathsEmpty() { + // Empty path lists should return empty result with a warning (not throw) + SecretRuleLoader.LoadedRules result = loader.loadAll(List.of()); + assertTrue(result.getRules().isEmpty(), "Should return empty rules list"); + assertNull(result.getGlobalAllowlist(), "Should return null global allowlist"); + } + + @Test + void loadSingle_throwsOnBlankPath() { + // A blank path is invalid and should surface as an exception, not silent fallback. + assertThrows(IllegalStateException.class, () -> loader.load(" ")); + } + + @Test + void loadSingle_parsesAllowlistsKeywordsAndSecretGroup() { + // The sample YAML contains allowlists, entropy, keywords, and a secret group. + List rules = loader.load("classpath:org/eclipse/openvsx/scanning/secret-rules-a.yaml"); + + assertEquals(1, rules.size()); + SecretRule rule = rules.get(0); + + assertEquals(2, rule.getAllowlistPatterns().size()); + assertEquals("test", rule.getAllowlistPatterns().get(0).pattern()); + assertEquals(1, rule.getSecretGroup()); + assertEquals(3.5, rule.getEntropy()); + assertEquals(List.of("token", "shared"), rule.getKeywords()); + } + + @Test + void loadAll_parsesGlobalAllowlistPaths() { + SecretRuleLoader.LoadedRules result = loader.loadAll(List.of( + "classpath:org/eclipse/openvsx/scanning/secret-rules-with-allowlist.yaml" + )); + + SecretRuleLoader.GlobalAllowlist allowlist = Objects.requireNonNull(result.getGlobalAllowlist()); + assertNotNull(allowlist.paths); + assertEquals(3, allowlist.paths.size()); + assertTrue(allowlist.paths.contains("node_modules/")); + assertTrue(allowlist.paths.contains(".git/")); + assertTrue(allowlist.paths.contains("test/")); + } + + @Test + void loadAll_parsesGlobalAllowlistRegexes() { + SecretRuleLoader.LoadedRules result = loader.loadAll(List.of( + "classpath:org/eclipse/openvsx/scanning/secret-rules-with-allowlist.yaml" + )); + + SecretRuleLoader.GlobalAllowlist allowlist = Objects.requireNonNull(result.getGlobalAllowlist()); + assertNotNull(allowlist.regexes); + assertEquals(3, allowlist.regexes.size()); + assertTrue(allowlist.regexes.contains("^test$")); + assertTrue(allowlist.regexes.contains("^example$")); + assertTrue(allowlist.regexes.contains(".+\\.min\\.js$")); + } + + @Test + void loadAll_parsesGlobalAllowlistStopwords() { + SecretRuleLoader.LoadedRules result = loader.loadAll(List.of( + "classpath:org/eclipse/openvsx/scanning/secret-rules-with-allowlist.yaml" + )); + + SecretRuleLoader.GlobalAllowlist allowlist = Objects.requireNonNull(result.getGlobalAllowlist()); + assertNotNull(allowlist.stopwords); + assertEquals(3, allowlist.stopwords.size()); + assertTrue(allowlist.stopwords.contains("example")); + assertTrue(allowlist.stopwords.contains("test")); + assertTrue(allowlist.stopwords.contains("dummy")); + } + + @Test + void loadAll_parsesGlobalAllowlistFileExtensions() { + SecretRuleLoader.LoadedRules result = loader.loadAll(List.of( + "classpath:org/eclipse/openvsx/scanning/secret-rules-with-allowlist.yaml" + )); + + SecretRuleLoader.GlobalAllowlist allowlist = Objects.requireNonNull(result.getGlobalAllowlist()); + assertNotNull(allowlist.fileExtensions); + assertEquals(3, allowlist.fileExtensions.size()); + assertTrue(allowlist.fileExtensions.contains(".png")); + assertTrue(allowlist.fileExtensions.contains(".jpg")); + assertTrue(allowlist.fileExtensions.contains(".zip")); + } + + @Test + void loadAll_handlesYamlWithoutGlobalAllowlist() { + SecretRuleLoader.LoadedRules result = loader.loadAll(List.of( + "classpath:org/eclipse/openvsx/scanning/secret-rules-a.yaml" + )); + + // Should return null when no global allowlist is present + assertNull(result.getGlobalAllowlist()); + // But rules should still be loaded + assertEquals(1, result.getRules().size()); + } + + @Test + void loadAll_mergesGlobalAllowlistsFromAllFiles() { + // When loading multiple files, all global allowlists should be merged + SecretRuleLoader.LoadedRules result = loader.loadAll(List.of( + "classpath:org/eclipse/openvsx/scanning/secret-rules-with-allowlist.yaml", // has allowlist + "classpath:org/eclipse/openvsx/scanning/secret-rules-allowlist-2.yaml" // has different allowlist + )); + + SecretRuleLoader.GlobalAllowlist allowlist = result.getGlobalAllowlist(); + assertNotNull(allowlist); + + // Paths should be merged (3 from first file + 2 from second = 5) + assertNotNull(allowlist.paths); + assertEquals(5, allowlist.paths.size()); + assertTrue(allowlist.paths.contains("node_modules/")); + assertTrue(allowlist.paths.contains(".git/")); + assertTrue(allowlist.paths.contains("test/")); + assertTrue(allowlist.paths.contains("vendor/")); + assertTrue(allowlist.paths.contains("build/")); + + // Regexes should be merged (3 + 2 = 5) + assertNotNull(allowlist.regexes); + assertEquals(5, allowlist.regexes.size()); + assertTrue(allowlist.regexes.contains("^test$")); + assertTrue(allowlist.regexes.contains("^example$")); + assertTrue(allowlist.regexes.contains("^placeholder$")); + assertTrue(allowlist.regexes.contains("^dummy$")); + + // Stopwords should be merged (3 + 2 = 5) + assertNotNull(allowlist.stopwords); + assertEquals(5, allowlist.stopwords.size()); + assertTrue(allowlist.stopwords.contains("example")); + assertTrue(allowlist.stopwords.contains("test")); + assertTrue(allowlist.stopwords.contains("dummy")); + assertTrue(allowlist.stopwords.contains("fake")); + assertTrue(allowlist.stopwords.contains("mock")); + + // File extensions should be merged (3 + 2 = 5) + assertNotNull(allowlist.fileExtensions); + assertEquals(5, allowlist.fileExtensions.size()); + assertTrue(allowlist.fileExtensions.contains(".png")); + assertTrue(allowlist.fileExtensions.contains(".jpg")); + assertTrue(allowlist.fileExtensions.contains(".zip")); + assertTrue(allowlist.fileExtensions.contains(".svg")); + assertTrue(allowlist.fileExtensions.contains(".gif")); + + // Both rules should be present (merged) + assertEquals(2, result.getRules().size()); + } + + @Test + void loadAll_returnsRulesAndGlobalAllowlistTogether() { + SecretRuleLoader.LoadedRules result = loader.loadAll(List.of( + "classpath:org/eclipse/openvsx/scanning/secret-rules-with-allowlist.yaml" + )); + + // Verify both components are present + assertNotNull(result.getRules()); + assertEquals(1, result.getRules().size()); + assertEquals("test-rule-1", result.getRules().get(0).getId()); + + SecretRuleLoader.GlobalAllowlist allowlist = result.getGlobalAllowlist(); + assertNotNull(allowlist); + assertNotNull(allowlist.paths); + assertNotNull(allowlist.regexes); + assertNotNull(allowlist.stopwords); + assertNotNull(allowlist.fileExtensions); + } + + @Test + void loadAll_mergesPartialAllowlists() { + // Test merging when some files have only partial allowlist fields + SecretRuleLoader.LoadedRules result = loader.loadAll(List.of( + "classpath:org/eclipse/openvsx/scanning/secret-rules-with-allowlist.yaml", // full allowlist + "classpath:org/eclipse/openvsx/scanning/secret-rules-allowlist-3.yaml" // partial (only paths and stopwords) + )); + + SecretRuleLoader.GlobalAllowlist allowlist = result.getGlobalAllowlist(); + assertNotNull(allowlist); + + // Paths: 3 from first + 1 from second = 4 + assertEquals(4, allowlist.paths.size()); + assertTrue(allowlist.paths.contains("dist/")); + + // Regexes: 3 from first + 0 from second = 3 + assertEquals(3, allowlist.regexes.size()); + + // Stopwords: 3 from first + 1 from second = 4 + assertEquals(4, allowlist.stopwords.size()); + assertTrue(allowlist.stopwords.contains("sample")); + + // File extensions: 3 from first + 0 from second = 3 + assertEquals(3, allowlist.fileExtensions.size()); + + // Rules should be merged (1 + 1 = 2) + assertEquals(2, result.getRules().size()); + } + + @Test + void loadAll_mergesAllowlistWhenFirstFileHasNone() { + // Test that allowlist from second file is used when first has none + SecretRuleLoader.LoadedRules result = loader.loadAll(List.of( + "classpath:org/eclipse/openvsx/scanning/secret-rules-a.yaml", // no allowlist + "classpath:org/eclipse/openvsx/scanning/secret-rules-with-allowlist.yaml" // has allowlist + )); + + SecretRuleLoader.GlobalAllowlist allowlist = result.getGlobalAllowlist(); + assertNotNull(allowlist); + assertEquals(3, allowlist.paths.size()); + assertEquals(3, allowlist.regexes.size()); + assertEquals(3, allowlist.stopwords.size()); + assertEquals(3, allowlist.fileExtensions.size()); + + // Both rules should be present + assertEquals(2, result.getRules().size()); + } + + @Test + void loadAll_mergesThreeFilesWithMixedAllowlists() { + // Test merging three files with different allowlist configurations + SecretRuleLoader.LoadedRules result = loader.loadAll(List.of( + "classpath:org/eclipse/openvsx/scanning/secret-rules-with-allowlist.yaml", // full + "classpath:org/eclipse/openvsx/scanning/secret-rules-a.yaml", // no allowlist + "classpath:org/eclipse/openvsx/scanning/secret-rules-allowlist-2.yaml" // full + )); + + SecretRuleLoader.GlobalAllowlist allowlist = result.getGlobalAllowlist(); + assertNotNull(allowlist); + + // Should have items from file 1 and file 3 (file 2 has no allowlist) + assertEquals(5, allowlist.paths.size()); // 3 + 2 + assertEquals(5, allowlist.regexes.size()); // 3 + 2 + assertEquals(5, allowlist.stopwords.size()); // 3 + 2 + assertEquals(5, allowlist.fileExtensions.size()); // 3 + 2 + + // All three rules should be present, but rule-a is overridden by file 2 + assertEquals(3, result.getRules().size()); + + // Verify rule-a was overridden (should have the description from file A, not the original) + SecretRule ruleA = result.getRules().stream() + .filter(r -> r.getId().equals("rule-a")) + .findFirst() + .orElseThrow(); + + // File 2 (secret-rules-a.yaml) is loaded second, so its version should win + assertEquals("a[0-9]{3}", ruleA.getPattern().pattern()); + assertEquals(List.of("token", "shared"), ruleA.getKeywords()); + } + + @Test + void loadAll_overridesRulesButMergesAllowlists() { + // Verify that rules are overridden (last wins) but allowlists are merged + SecretRuleLoader.LoadedRules result = loader.loadAll(List.of( + "classpath:org/eclipse/openvsx/scanning/secret-rules-with-allowlist.yaml", // test-rule-1 + "classpath:org/eclipse/openvsx/scanning/secret-rules-allowlist-2.yaml", // test-rule-2 + "classpath:org/eclipse/openvsx/scanning/secret-rules-allowlist-3.yaml" // test-rule-3 + )); + + // All three unique rules should be present + assertEquals(3, result.getRules().size()); + + List ruleIds = result.getRules().stream() + .map(SecretRule::getId) + .sorted() + .toList(); + assertEquals(List.of("test-rule-1", "test-rule-2", "test-rule-3"), ruleIds); + + // All allowlists should be merged + SecretRuleLoader.GlobalAllowlist allowlist = result.getGlobalAllowlist(); + assertNotNull(allowlist); + + // Paths: 3 + 2 + 1 = 6 + assertEquals(6, allowlist.paths.size()); + + // Regexes: 3 + 2 + 0 = 5 + assertEquals(5, allowlist.regexes.size()); + + // Stopwords: 3 + 2 + 1 = 6 + assertEquals(6, allowlist.stopwords.size()); + + // File extensions: 3 + 2 + 0 = 5 + assertEquals(5, allowlist.fileExtensions.size()); + } + + @Test + void loadAll_allowsDuplicateAllowlistItems() { + // If multiple files specify the same allowlist item, it should appear multiple times + // (caller can deduplicate if needed, but loader preserves all) + SecretRuleLoader.LoadedRules result = loader.loadAll(List.of( + "classpath:org/eclipse/openvsx/scanning/secret-rules-with-allowlist.yaml", + "classpath:org/eclipse/openvsx/scanning/secret-rules-with-allowlist.yaml" // same file twice + )); + + SecretRuleLoader.GlobalAllowlist allowlist = result.getGlobalAllowlist(); + assertNotNull(allowlist); + + // Items should be duplicated (3 + 3 = 6) + assertEquals(6, allowlist.paths.size()); + assertEquals(6, allowlist.regexes.size()); + assertEquals(6, allowlist.stopwords.size()); + assertEquals(6, allowlist.fileExtensions.size()); + + // But rules should not be duplicated (same ID) + assertEquals(1, result.getRules().size()); + } +} + diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/SecretScanResultTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/SecretScanResultTest.java new file mode 100644 index 000000000..195fa8941 --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/scanning/SecretScanResultTest.java @@ -0,0 +1,64 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link SecretScanResult}. + * These keep the simple value object behavior well defined and documented. + */ +class SecretScanResultTest { + + @Test + void secretsFound_rejectsEmptyFindingList() { + // We should never create a positive result with no findings. + assertThrows(IllegalArgumentException.class, () -> SecretScanResult.secretsFound(List.of())); + } + + @Test + void secretsFound_wrapsFindingsImmutably() { + // Build a single finding to ensure the result reflects its data. + SecretFinding finding = new SecretFinding("file.txt", 2, 4.2, "supersecret", "rule-a"); + SecretScanResult result = SecretScanResult.secretsFound(List.of(finding)); + + assertTrue(result.isSecretsFound()); + assertEquals(1, result.getFindings().size()); + assertEquals("Found 1 potential secret in extension package", result.getSummaryMessage()); + + // Verify callers cannot mutate the internal list. + assertThrows(UnsupportedOperationException.class, () -> result.getFindings().add(finding)); + } + + @Test + void noSecretsFoundAndSkippedShareSafeDefaults() { + // Both helpers should produce empty, immutable findings and the "no secrets" summary. + List results = List.of( + SecretScanResult.noSecretsFound(), + SecretScanResult.skipped() + ); + + for (SecretScanResult result : results) { + assertFalse(result.isSecretsFound()); + assertTrue(result.getFindings().isEmpty()); + assertEquals("No secrets detected", result.getSummaryMessage()); + assertThrows(UnsupportedOperationException.class, () -> result.getFindings().add( + new SecretFinding("file", 1, 1.0, "value", "rule"))); + } + } +} + diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/SecretScannerFactoryTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/SecretScannerFactoryTest.java new file mode 100644 index 000000000..ce4028e2b --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/scanning/SecretScannerFactoryTest.java @@ -0,0 +1,158 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for the lightweight wiring performed by {@link SecretScannerFactory}. + * These tests avoid the Spring context and keep assertions close to the wiring logic. + */ +class SecretScannerFactoryTest { + + @Test + void initialize_buildsEvenWhenDisabled() throws Exception { + // Factory initializes if rule paths are configured, even when publishing-time scanning is disabled + TrackingRuleLoader loader = new TrackingRuleLoader(); + SecretScanningConfiguration config = buildConfig(false); // disabled + setField(config, "rulesPath", "classpath:org/eclipse/openvsx/scanning/secret-rules-a.yaml"); // Use test resource + MockGitleaksRulesGenerator generator = new MockGitleaksRulesGenerator(null); + SecretScannerFactory factory = new SecretScannerFactory(loader, config, generator); + + factory.initialize(); + + assertTrue(loader.wasCalled, "Loader should run when rule paths are configured"); + assertNotNull(factory.getScanner(), "Scanner should be created for retroactive scan use cases"); + assertEquals(1, factory.getRules().size(), "Rules should be loaded"); + assertFalse(factory.getKeywordToRules().isEmpty(), "Keyword index should be built"); + } + + @Test + void initialize_skipsWhenNotConfigured() throws Exception { + // Factory skips initialization if ovsx.secret-scanning block is not present + TrackingRuleLoader loader = new TrackingRuleLoader(); + SecretScanningConfiguration config = buildConfig(false); // disabled + MockGitleaksRulesGenerator generator = new MockGitleaksRulesGenerator(null); + SecretScannerFactory factory = new SecretScannerFactory(loader, config, generator); + + factory.initialize(); + + assertFalse(loader.wasCalled, "Loader should not run when secret scanning is not configured"); + assertNull(factory.getScanner(), "Scanner should not be created when not configured"); + assertTrue(factory.getRules().isEmpty(), "Rules should remain empty"); + assertTrue(factory.getKeywordToRules().isEmpty(), "Keyword index should remain empty"); + } + + @Test + void initialize_buildsMatchersAndIndexes() throws Exception { + TrackingRuleLoader loader = new TrackingRuleLoader(); + SecretScanningConfiguration config = buildConfig(true); + setField(config, "rulesPath", + "classpath:org/eclipse/openvsx/scanning/secret-rules-a.yaml," + + "classpath:org/eclipse/openvsx/scanning/secret-rules-b.yaml"); + MockGitleaksRulesGenerator generator = new MockGitleaksRulesGenerator(null); + + SecretScannerFactory factory = new SecretScannerFactory(loader, config, generator); + + factory.initialize(); + + assertTrue(loader.wasCalled, "Loader should run when scanning is enabled"); + assertNotNull(factory.getScanner(), "Scanner should be created from the loaded rules"); + assertEquals(2, factory.getRules().size(), "Expected merged rules from both YAML files"); + + // Keyword index should include the lower-cased keyword from the override rule. + assertTrue(factory.getKeywordToRules().containsKey("overridekey")); + assertEquals("rule-a", factory.getKeywordToRules().get("overridekey").get(0).getId()); + + // The matcher should actually find the keyword in a sample string. + List matches = factory.getKeywordMatcher().search("prefix overridekey suffix"); + assertFalse(matches.isEmpty(), "Keyword matcher should find at least one match"); + assertEquals("overridekey", matches.get(0).getKeyword()); + } + + @Test + void initialize_loadsGlobalAllowlistFromYaml() throws Exception { + SecretRuleLoader loader = new SecretRuleLoader(); + SecretScanningConfiguration config = buildConfig(true); + setField(config, "rulesPath", "classpath:org/eclipse/openvsx/scanning/secret-rules-with-allowlist.yaml"); + MockGitleaksRulesGenerator generator = new MockGitleaksRulesGenerator(null); + + SecretScannerFactory factory = new SecretScannerFactory(loader, config, generator); + factory.initialize(); + + assertNotNull(factory.getScanner(), "Scanner should be created"); + assertEquals(1, factory.getRules().size()); + + // Verify scanner was initialized (global allowlist is used internally) + // We can't directly inspect the scanner's internal allowlist, but we can verify it was created + assertNotNull(factory.getScanner()); + } + + private SecretScanningConfiguration buildConfig(boolean enabled) throws Exception { + // We set all the primitive fields explicitly so the factory can use getters safely. + SecretScanningConfiguration config = new SecretScanningConfiguration(); + setField(config, "enabled", enabled); + setField(config, "maxFileSizeBytes", 1024 * 1024); + setField(config, "maxLineLength", 10_000); + setField(config, "inlineSuppressionsString", ""); + setField(config, "timeoutSeconds", 5); + setField(config, "maxEntryCount", 100); + setField(config, "maxTotalUncompressedBytes", 1024 * 1024); + setField(config, "maxFindings", 10); + setField(config, "timeoutCheckEveryNLines", 10); + setField(config, "longLineNoSpaceThreshold", 80); + setField(config, "keywordContextChars", 10); + setField(config, "logAllowlistedPreviewLength", 4); + return config; + } + + private void setField(Object target, String name, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + private static final class TrackingRuleLoader extends SecretRuleLoader { + boolean wasCalled = false; + + @Override + public LoadedRules loadAll(List paths) { + wasCalled = true; + // Delegate to the real loader so the behavior stays realistic. + return super.loadAll(paths); + } + } + + /** + * Mock generator for testing factory integration. + */ + static class MockGitleaksRulesGenerator extends GitleaksRulesGenerator { + private final String mockPath; + + MockGitleaksRulesGenerator(String mockPath) { + super(null); // Don't need real config for mock + this.mockPath = mockPath; + } + + @Override + public String getGeneratedRulesPath() { + return mockPath; + } + } +} + diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/SecretScannerTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/SecretScannerTest.java new file mode 100644 index 000000000..4f8b0cab4 --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/scanning/SecretScannerTest.java @@ -0,0 +1,768 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import com.google.re2j.Pattern; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Focused tests for {@link SecretScanner} that exercise keyword routing, allowlists, + * inline suppressions, and excluded path handling without using the full service. + */ +class SecretScannerTest { + + private final List toDelete = new ArrayList<>(); + + @AfterEach + void cleanup() throws IOException { + for (Path p : toDelete) { + Files.deleteIfExists(p); + } + toDelete.clear(); + } + + @Test + void scanFile_detectsSecretThroughKeywordRouting() throws Exception { + SecretRule rule = new SecretRule.Builder() + .id("rule-kw") + .description("keyword rule") + .regex("token([A-Za-z0-9]{9})") + .keywords("token") + .build(); + + SecretScanner scanner = buildScanner( + Map.of("token", List.of(rule)), + List.of(rule), + /*allowlistPatterns*/ List.of(), + /*stopwords*/ List.of(), + /*inlineSuppressions*/ List.of(), + /*excludedPathPatterns*/ List.of(), + /*excludedExtensions*/ List.of()); + + Path zipPath = createZipWithEntry("src/file.txt", "here is tokenABCDEF123 on line\n"); + toDelete.add(zipPath); + + List findings = new ArrayList<>(); + boolean scanned = runScan(scanner, zipPath, "src/file.txt", findings); + + assertTrue(scanned, "File should be scanned"); + assertEquals(1, findings.size(), "One finding should be recorded"); + SecretFinding finding = findings.get(0); + assertEquals("rule-kw", finding.getRuleId()); + assertEquals("src/file.txt", finding.getFilePath()); + assertEquals(1, finding.getLineNumber()); + } + + @Test + void scanFile_skipsAllowlistedAndInlineSuppressedContent() throws Exception { + // Rule-level allowlist should skip matching content and inline suppression should skip the line. + SecretRule rule = new SecretRule.Builder() + .id("rule-allow") + .description("allowlist rule") + .regex("password-([A-Za-z0-9]{8,})") + .keywords("password") + .allowlistRegexes(List.of("ALLOWED123")) + .build(); + + SecretScanner scanner = buildScanner( + Map.of("password", List.of(rule)), + List.of(rule), + /*allowlistPatterns*/ List.of(), + /*stopwords*/ List.of(), + /*inlineSuppressions*/ List.of("secret-scanner:ignore"), + /*excludedPathKeywords*/ List.of(), + /*excludedExtensions*/ List.of()); + + String content = String.join("\n", + "password-ALLOWED123 should be allowlisted", + "password-BLOCKME456 secret-scanner:ignore inline suppression"); + + Path zipPath = createZipWithEntry("pkg/secret.txt", content); + toDelete.add(zipPath); + + List findings = new ArrayList<>(); + boolean scanned = runScan(scanner, zipPath, "pkg/secret.txt", findings); + + assertTrue(scanned, "File should be scanned"); + assertTrue(findings.isEmpty(), "Allowlist and inline suppression should prevent findings"); + } + + @Test + void scanFile_respectsExcludedPathMatcher() throws Exception { + SecretRule rule = new SecretRule.Builder() + .id("rule-basic") + .description("basic rule") + .regex("secret-([A-Za-z0-9]{8})") + .build(); + + SecretScanner scanner = buildScanner( + Map.of(), + List.of(rule), + /*allowlistPatterns*/ List.of(), + /*stopwords*/ List.of(), + /*inlineSuppressions*/ List.of(), + /*excludedPathPatterns*/ List.of("node_modules"), + /*excludedExtensions*/ List.of()); + + Path zipPath = createZipWithEntry("node_modules/secret.txt", "secret-ABCDEF12\n"); + toDelete.add(zipPath); + + List findings = new ArrayList<>(); + boolean scanned = runScan(scanner, zipPath, "node_modules/secret.txt", findings); + + assertFalse(scanned, "Excluded paths should be skipped"); + assertTrue(findings.isEmpty(), "No findings should be recorded for excluded files"); + } + + @Test + void scanFile_skipsStopwordsAndGlobalAllowlist() throws Exception { + SecretRule rule = new SecretRule.Builder() + .id("rule-stop") + .description("stopword rule") + .regex("token-([A-Za-z0-9]{6,})") + .keywords("token-") + .build(); + + // Global allowlist and stopwords should prevent recording. + SecretScanner scanner = buildScanner( + Map.of("token-", List.of(rule)), + List.of(rule), + /*allowlistPatterns*/ List.of(Pattern.compile("ALLOWED", Pattern.CASE_INSENSITIVE)), + /*stopwords*/ List.of("placeholder"), + /*inlineSuppressions*/ List.of(), + /*excludedPathKeywords*/ List.of(), + /*excludedExtensions*/ List.of()); + + String content = "token-ALLOWEDplaceholder"; + Path zipPath = createZipWithEntry("src/stopword.txt", content); + toDelete.add(zipPath); + + List findings = new ArrayList<>(); + boolean scanned = runScan(scanner, zipPath, "src/stopword.txt", findings); + + assertTrue(scanned, "File should be scanned"); + assertTrue(findings.isEmpty(), "Stopwords or global allowlists should suppress findings"); + } + + @Test + void scanFile_skipsExcludedExtensions() throws Exception { + SecretRule rule = new SecretRule.Builder() + .id("rule-ext") + .description("extension rule") + .regex("secret-([A-Za-z0-9]{8})") + .build(); + + SecretScanner scanner = buildScanner( + Map.of(), + List.of(rule), + /*allowlistPatterns*/ List.of(), + /*stopwords*/ List.of(), + /*inlineSuppressions*/ List.of(), + /*excludedPathKeywords*/ List.of(), + /*excludedExtensions*/ List.of(".log")); + + Path zipPath = createZipWithEntry("notes/secret.log", "secret-ABCD1234\n"); + toDelete.add(zipPath); + + List findings = new ArrayList<>(); + boolean scanned = runScan(scanner, zipPath, "notes/secret.log", findings); + + assertFalse(scanned, "Excluded extensions should prevent scanning"); + assertTrue(findings.isEmpty(), "No findings should be recorded for excluded extensions"); + } + + @Test + void scanFile_defaultsSecretGroupWhenOutOfRange() throws Exception { + // secretGroup is intentionally out of bounds; scanner should fall back to the default group. + SecretRule rule = new SecretRule.Builder() + .id("rule-group") + .description("group fallback rule") + .regex("(supersecret[0-9]{3})(extra)?") + .secretGroup(5) // invalid index, should fall back to group 1 + .build(); + + SecretScanner scanner = buildScanner( + Map.of(), + List.of(rule), + /*allowlistPatterns*/ List.of(), + /*stopwords*/ List.of(), + /*inlineSuppressions*/ List.of(), + /*excludedPathPatterns*/ List.of(), + /*excludedExtensions*/ List.of()); + + Path zipPath = createZipWithEntry("src/group.txt", "prefix supersecret999 suffix\n"); + toDelete.add(zipPath); + + List findings = new ArrayList<>(); + boolean scanned = runScan(scanner, zipPath, "src/group.txt", findings); + + assertTrue(scanned, "File should be scanned"); + assertEquals(1, findings.size(), "Fallback to default group should still record a finding"); + assertEquals("rule-group", findings.get(0).getRuleId()); + } + + @Test + void scanFile_enforcesEntropyThreshold() throws Exception { + SecretRule rule = new SecretRule.Builder() + .id("rule-entropy") + .description("entropy rule") + .regex("token([A-Za-z0-9]{8,})") + .keywords("token") + .entropy(4.5) + .build(); + + SecretScanner scanner = buildScanner( + Map.of("token", List.of(rule)), + List.of(rule), + /*allowlistPatterns*/ List.of(), + /*stopwords*/ List.of(), + /*inlineSuppressions*/ List.of(), + /*excludedPathPatterns*/ List.of(), + /*excludedExtensions*/ List.of()); + + String content = String.join("\n", + "tokenaaaaaaaaaaaa low entropy should be ignored", + "tokenA1b2C3d4E5f6G7h8J9K0LmNo high entropy should be recorded"); + + Path zipPath = createZipWithEntry("src/entropy.txt", content); + toDelete.add(zipPath); + + List findings = new ArrayList<>(); + boolean scanned = runScan(scanner, zipPath, "src/entropy.txt", findings); + + assertTrue(scanned, "File should be scanned"); + assertEquals(1, findings.size(), "Only high-entropy secret should be recorded"); + assertEquals("rule-entropy", findings.get(0).getRuleId()); + } + + @Test + void scanFile_skipsVeryLongLines() throws Exception { + SecretRule rule = new SecretRule.Builder() + .id("rule-long") + .description("long line rule") + .regex("secret-[A-Za-z0-9]+") + .build(); + + SecretScanner scanner = buildScanner( + Map.of(), + List.of(rule), + /*allowlistPatterns*/ List.of(), + /*stopwords*/ List.of(), + /*inlineSuppressions*/ List.of(), + /*excludedPathPatterns*/ List.of(), + /*excludedExtensions*/ List.of()); + + // Create a single line longer than the scanner's maxLineLength (10_000) to trigger the skip. + String longLine = "secret-START-" + "x".repeat(11_000); + + Path zipPath = createZipWithEntry("src/long.txt", longLine); + toDelete.add(zipPath); + + List findings = new ArrayList<>(); + boolean scanned = runScan(scanner, zipPath, "src/long.txt", findings); + + assertEquals(0, findings.size(), "No findings should be recorded for skipped files"); + assertTrue(findings.isEmpty(), "No findings should be recorded for skipped files"); + } + + private SecretScanner buildScanner(Map> keywordToRules, + List rules, + List allowlistPatterns, + List stopwords, + List inlineSuppressions, + List excludedPathPatterns, + List excludedExtensions) { + AhoCorasick keywordMatcher = new AhoCorasick(); + keywordMatcher.build(keywordToRules.keySet()); + + AhoCorasick stopwordMatcher = null; + if (!stopwords.isEmpty()) { + stopwordMatcher = new AhoCorasick(); + stopwordMatcher.build(new java.util.HashSet<>(stopwords)); + } + + List pathPatterns = null; + if (!excludedPathPatterns.isEmpty()) { + pathPatterns = excludedPathPatterns.stream() + .map(p -> Pattern.compile(p, Pattern.CASE_INSENSITIVE)) + .toList(); + } + + AhoCorasick excludedExtensionMatcher = null; + if (!excludedExtensions.isEmpty()) { + excludedExtensionMatcher = new AhoCorasick(); + excludedExtensionMatcher.build(new java.util.HashSet<>(excludedExtensions)); + } + + // Build Aho-Corasick matcher for inline suppressions + AhoCorasick inlineSuppressionMatcher = null; + if (!inlineSuppressions.isEmpty()) { + inlineSuppressionMatcher = new AhoCorasick(); + inlineSuppressionMatcher.build(new java.util.HashSet<>(inlineSuppressions)); + } + + return new SecretScanner( + keywordMatcher, + keywordToRules, + rules, + allowlistPatterns, + pathPatterns, + stopwordMatcher, + excludedExtensionMatcher, + inlineSuppressionMatcher, + new EntropyCalculator(), + /*maxFileSizeBytes*/ 1_000_000, + /*maxLineLength*/ 10_000, + /*timeoutCheckEveryNLines*/ 1_000, + /*longLineNoSpaceThreshold*/ 500, + /*keywordContextChars*/ 50, + /*logAllowlistedPreviewLength*/ 6 + ); + } + + private Path createZipWithEntry(String entryName, String content) throws IOException { + Path zipPath = Files.createTempFile("secret-scan-", ".zip"); + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipPath))) { + ZipEntry entry = new ZipEntry(entryName); + byte[] bytes = content.getBytes(java.nio.charset.StandardCharsets.UTF_8); + entry.setSize(bytes.length); + zos.putNextEntry(entry); + zos.write(bytes); + zos.closeEntry(); + } + return zipPath; + } + + private boolean runScan(SecretScanner scanner, + Path zipPath, + String entryName, + List findings) throws Exception { + try (ZipFile zipFile = new ZipFile(zipPath.toFile())) { + ZipEntry entry = zipFile.getEntry(entryName); + assertNotNull(entry, "Zip entry should exist"); + + AtomicInteger counter = new AtomicInteger(); + long start = System.currentTimeMillis(); + long timeoutMillis = 5_000; + return scanner.scanFile( + zipFile, + entry, + findings, + start, + timeoutMillis, + counter, + (list, count, finding) -> { + list.add(finding); + count.incrementAndGet(); + return true; + } + ); + } + } + + private boolean runScanWithTimeout(SecretScanner scanner, + Path zipPath, + String entryName, + List findings, + long startTime, + long timeoutMillis) throws Exception { + try (ZipFile zipFile = new ZipFile(zipPath.toFile())) { + ZipEntry entry = zipFile.getEntry(entryName); + assertNotNull(entry, "Zip entry should exist"); + + AtomicInteger counter = new AtomicInteger(); + return scanner.scanFile( + zipFile, + entry, + findings, + startTime, + timeoutMillis, + counter, + (list, count, finding) -> { + list.add(finding); + count.incrementAndGet(); + return true; + } + ); + } + } + + @Test + void scanFile_throwsTimeoutWhenExceedingLimit() throws Exception { + // Create scanner with frequent timeout checks + SecretRule rule = new SecretRule.Builder() + .id("timeout-rule") + .description("Rule for timeout test") + .regex("secret-[0-9]+") + .keywords("secret") + .build(); + + Map> keywordIndex = Map.of("secret", List.of(rule)); + AhoCorasick keywordMatcher = new AhoCorasick(); + keywordMatcher.build(Set.of("secret")); + + SecretScanner scanner = new SecretScanner( + keywordMatcher, + keywordIndex, + List.of(rule), + /*allowlistPatterns*/ List.of(), + /*excludedPathPatterns*/ List.of(), + /*stopwordMatcher*/ null, + /*excludedExtensionMatcher*/ null, + /*inlineSuppressionMatcher*/ null, + new EntropyCalculator(), + /*maxFileSizeBytes*/ 1_000_000, + /*maxLineLength*/ 10_000, + /*timeoutCheckEveryNLines*/ 5, // Check every 5 lines + /*longLineNoSpaceThreshold*/ 500, + /*keywordContextChars*/ 50, + /*logAllowlistedPreviewLength*/ 6 + ); + + // Create file with many lines + StringBuilder content = new StringBuilder(); + for (int i = 0; i < 100; i++) { + content.append("line ").append(i).append(" with content\n"); + } + + Path zipPath = createZipWithEntry("test.txt", content.toString()); + toDelete.add(zipPath); + + List findings = new ArrayList<>(); + + // Set start time in the past to simulate timeout + long startTime = System.currentTimeMillis() - 10_000; // 10 seconds ago + long timeoutMillis = 100; // Very short timeout + + // Should throw SecretScanningTimeoutException + Exception exception = assertThrows(Exception.class, + () -> runScanWithTimeout(scanner, zipPath, "test.txt", findings, startTime, timeoutMillis)); + + assertTrue(exception.getMessage().contains("timed out"), + "Exception should mention timeout: " + exception.getMessage()); + } + + @Test + void scanFile_respectsTimeoutCheckFrequency() throws Exception { + // Verify timeout is only checked every N lines + // With a high check frequency, scanning should complete even with expired time + SecretRule rule = new SecretRule.Builder() + .id("freq-rule") + .description("Rule for frequency test") + .regex("secret-[0-9]+") + .build(); + + SecretScanner scanner = buildScanner( + Map.of(), + List.of(rule), + /*allowlistPatterns*/ List.of(), + /*stopwords*/ List.of(), + /*inlineSuppressions*/ List.of(), + /*excludedPathPatterns*/ List.of(), + /*excludedExtensions*/ List.of()); + + // Create file with 50 lines + StringBuilder content = new StringBuilder(); + for (int i = 0; i < 50; i++) { + content.append("line ").append(i).append("\n"); + } + + Path zipPath = createZipWithEntry("test.txt", content.toString()); + toDelete.add(zipPath); + + List findings = new ArrayList<>(); + + // Use a reasonable timeout that won't be exceeded during normal execution + long startTime = System.currentTimeMillis(); + long timeoutMillis = 5000; // 5 seconds should be plenty + + // Should complete successfully + boolean scanned = runScanWithTimeout(scanner, zipPath, "test.txt", findings, startTime, timeoutMillis); + + assertTrue(scanned, "Should complete scan successfully"); + } + + @Test + void scanFile_rejectsFileExceedingByteLimit() throws Exception { + // Test that files exceeding the byte limit are rejected by one of two defenses: + // 1. Early check: entry.getSize() > maxFileSizeBytes + // 2. Stream limit: SizeLimitInputStream counts actual bytes read + + SecretRule rule = new SecretRule.Builder() + .id("test-rule") + .description("Test rule") + .regex("secret-[0-9]+") + .keywords("secret") + .build(); + + Map> keywordIndex = Map.of("secret", List.of(rule)); + AhoCorasick keywordMatcher = new AhoCorasick(); + keywordMatcher.build(Set.of("secret")); + + long maxFileSizeBytes = 100; + + SecretScanner scanner = new SecretScanner( + keywordMatcher, + keywordIndex, + List.of(rule), + List.of(), + List.of(), + null, + null, + null, + new EntropyCalculator(), + maxFileSizeBytes, + 10_000, + 100, + 500, + 50, + 6 + ); + + // Create file with 200 bytes (exceeds 100 byte limit) + byte[] largeContent = new byte[200]; + java.util.Arrays.fill(largeContent, (byte) 'A'); + + Path largePath = Files.createTempFile("large-", ".zip"); + toDelete.add(largePath); + + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(largePath))) { + ZipEntry entry = new ZipEntry("large.txt"); + zos.putNextEntry(entry); + zos.write(largeContent); + zos.closeEntry(); + } + + // Scan should be rejected + try (ZipFile zipFile = new ZipFile(largePath.toFile())) { + ZipEntry entry = zipFile.getEntry("large.txt"); + long declaredSize = entry.getSize(); + + List findings = new ArrayList<>(); + AtomicInteger counter = new AtomicInteger(0); + + // Early check will reject this + boolean result = scanner.scanFile( + zipFile, + entry, + findings, + System.currentTimeMillis(), + 5000, + counter, + (list, count, finding) -> { + list.add(finding); + count.incrementAndGet(); + return true; + } + ); + assertFalse(result, "Early size check should reject file with size " + declaredSize + " > limit " + maxFileSizeBytes); + } + } + + @Test + void scanFile_acceptsFileWithinByteLimit() throws Exception { + // Test that files within the byte limit are scanned successfully + + SecretRule rule = new SecretRule.Builder() + .id("test-rule") + .description("Test rule") + .regex("secret-[0-9]+") + .keywords("secret") + .build(); + + Map> keywordIndex = Map.of("secret", List.of(rule)); + AhoCorasick keywordMatcher = new AhoCorasick(); + keywordMatcher.build(Set.of("secret")); + + long maxFileSizeBytes = 100; + + SecretScanner scanner = new SecretScanner( + keywordMatcher, + keywordIndex, + List.of(rule), + List.of(), + List.of(), + null, + null, + null, + new EntropyCalculator(), + maxFileSizeBytes, + 10_000, + 100, + 500, + 50, + 6 + ); + + // Create file with 50 bytes (within 100 byte limit) + byte[] validContent = new byte[50]; + java.util.Arrays.fill(validContent, (byte) 'B'); + + Path validPath = Files.createTempFile("valid-", ".zip"); + toDelete.add(validPath); + + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(validPath))) { + ZipEntry entry = new ZipEntry("valid.txt"); + zos.putNextEntry(entry); + zos.write(validContent); + zos.closeEntry(); + } + + // Scan should succeed + try (ZipFile zipFile = new ZipFile(validPath.toFile())) { + ZipEntry entry = zipFile.getEntry("valid.txt"); + List findings = new ArrayList<>(); + AtomicInteger counter = new AtomicInteger(0); + + boolean result = scanner.scanFile( + zipFile, + entry, + findings, + System.currentTimeMillis(), + 5000, + counter, + (list, count, finding) -> { + list.add(finding); + count.incrementAndGet(); + return true; + } + ); + + assertTrue(result, "File within byte limit should be scanned successfully"); + } + } + + @Test + void scanFile_streamLimitProtectsAgainstZipBombs() throws Exception { + // Test that SizeLimitInputStream defends against ZIP BOMB attacks where: + // - Entry header CLAIMS small size (e.g., 50 bytes - passes early check) + // - But actual decompression produces MUCH more data (e.g., 500 bytes) + // - SizeLimitInputStream counts ACTUAL bytes read and throws immediately + // + // This simulates a malicious ZIP where the header lies about the uncompressed size. + // We use Mockito to make getSize() return a fake small value while the actual + // content is large, demonstrating the defense-in-depth protection. + + SecretRule rule = new SecretRule.Builder() + .id("test-rule") + .description("Test rule") + .regex("secret-[0-9]+") + .keywords("secret") + .build(); + + Map> keywordIndex = Map.of("secret", List.of(rule)); + AhoCorasick keywordMatcher = new AhoCorasick(); + keywordMatcher.build(Set.of("secret")); + + long maxFileSizeBytes = 100; + + SecretScanner scanner = new SecretScanner( + keywordMatcher, + keywordIndex, + List.of(rule), + List.of(), + List.of(), + null, + null, + null, + new EntropyCalculator(), + maxFileSizeBytes, + 10_000, + 100, + 500, + 50, + 6 + ); + + // Create actual content: 500 bytes (way over 100 byte limit) + byte[] bombContent = new byte[500]; + java.util.Arrays.fill(bombContent, (byte) 'X'); + + Path bombPath = Files.createTempFile("bomb-", ".zip"); + toDelete.add(bombPath); + + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(bombPath))) { + ZipEntry entry = new ZipEntry("bomb.txt"); + zos.putNextEntry(entry); + zos.write(bombContent); + zos.closeEntry(); + } + + // Mock the ZipFile and ZipEntry to simulate a ZIP bomb + // The entry claims 50 bytes (passes early check) but actual content is 500 bytes + try (ZipFile realZipFile = new ZipFile(bombPath.toFile())) { + ZipFile mockedZipFile = spy(realZipFile); + ZipEntry realEntry = realZipFile.getEntry("bomb.txt"); + ZipEntry mockedEntry = spy(realEntry); + + // ATTACK SIMULATION: Entry header claims 50 bytes (< 100 limit) + // This bypasses the early size check + when(mockedEntry.getSize()).thenReturn(50L); + when(mockedEntry.getCompressedSize()).thenReturn(50L); + when(mockedZipFile.getEntry("bomb.txt")).thenReturn(mockedEntry); + + // But getInputStream() returns the real stream with 500 bytes + when(mockedZipFile.getInputStream(mockedEntry)) + .thenReturn(realZipFile.getInputStream(realEntry)); + + List findings = new ArrayList<>(); + AtomicInteger counter = new AtomicInteger(0); + + // The early check sees 50 bytes and passes + // But SizeLimitInputStream counts the ACTUAL 500 bytes being read + // and throws IOException when it exceeds the 100 byte limit + Exception exception = assertThrows(IOException.class, () -> + scanner.scanFile( + mockedZipFile, + mockedEntry, + findings, + System.currentTimeMillis(), + 5000, + counter, + (list, count, finding) -> { + list.add(finding); + count.incrementAndGet(); + return true; + } + ) + ); + + assertTrue(exception.getMessage().contains("exceeds limit"), + "SizeLimitInputStream should catch ZIP bomb during stream reading: " + exception.getMessage()); + assertTrue(exception.getMessage().contains(String.valueOf(maxFileSizeBytes)), + "Error should mention the " + maxFileSizeBytes + " byte limit: " + exception.getMessage()); + + // Verify the mock was called, proving the early check saw the fake size + verify(mockedEntry, atLeastOnce()).getSize(); + } + } +} + + diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/SecretScanningServiceTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/SecretScanningServiceTest.java new file mode 100644 index 000000000..e5036e2c8 --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/scanning/SecretScanningServiceTest.java @@ -0,0 +1,142 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import org.junit.jupiter.api.Test; +import org.eclipse.openvsx.util.ArchiveUtil; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.ZipEntry; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for guardrails and helpers inside SecretScanningService. + * We use reflection to keep production visibility unchanged while ensuring coverage. + */ +class SecretScanningServiceTest { + + @Test + void enforceArchiveLimits_throwsWhenEntryCountExceeded() { + List entries = List.of(new ZipEntry("a"), new ZipEntry("b")); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> ArchiveUtil.enforceArchiveLimits(entries, 1, 10_000)); + assertTrue(ex.getMessage().contains("too many entries")); + } + + @Test + void enforceArchiveLimits_throwsWhenTotalSizeExceeded() { + ZipEntry e1 = new ZipEntry("a"); + e1.setSize(6); + ZipEntry e2 = new ZipEntry("b"); + e2.setSize(6); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> ArchiveUtil.enforceArchiveLimits(List.of(e1, e2), 10, 10)); + assertTrue(ex.getMessage().contains("Uncompressed archive size exceeds")); + } + + @Test + void isSafePath_rejectsTraversalAndAbsolute() { + assertFalse(ArchiveUtil.isSafePath("../evil")); + assertFalse(ArchiveUtil.isSafePath("/abs/path")); + } + + @Test + void isSafePath_allowsNormalRelative() { + assertTrue(ArchiveUtil.isSafePath("folder/file.txt")); + } + + @Test + void recordFinding_respectsGlobalCap() throws Exception { + SecretScanningService service = buildServiceWithLimits(10, 100, 1); + List findings = new ArrayList<>(); + AtomicInteger count = new AtomicInteger(0); + + SecretFinding first = new SecretFinding( + "a.txt", 1, 4.0, "secretvalue", "rule1"); + SecretFinding second = new SecretFinding( + "b.txt", 2, 4.0, "secretvalue2", "rule2"); + + assertTrue(invokeRecordFinding(service, findings, count, first)); + assertEquals(1, findings.size()); + assertThrows(SecretScanningService.ScanCancelledException.class, + () -> invokeRecordFinding(service, findings, count, second)); + assertEquals(1, findings.size()); + } + + @Test + void loadRules_failsFastWhenMissing() { + var loader = new SecretRuleLoader(); + assertThrows(IllegalStateException.class, () -> loader.load("non-existent-rules.yaml")); + } + + // --- Helpers ---------------------------------------------------------------- + + private SecretScanningService buildServiceWithLimits(int maxEntries, long maxBytes, int maxFindings) throws Exception { + SecretScanningConfiguration config = new SecretScanningConfiguration(); + setField(config, "enabled", true); + setField(config, "maxFileSizeBytes", 1024 * 1024); + setField(config, "maxLineLength", 10_000); + setField(config, "timeoutSeconds", 10); + setField(config, "maxEntryCount", maxEntries); + setField(config, "maxTotalUncompressedBytes", maxBytes); + setField(config, "maxFindings", maxFindings); + setField(config, "rulesPath", "classpath:org/eclipse/openvsx/scanning/secret-rules-a.yaml"); + setField(config, "timeoutCheckEveryNLines", 100); + setField(config, "longLineNoSpaceThreshold", 1000); + setField(config, "keywordContextChars", 100); + setField(config, "logAllowlistedPreviewLength", 10); + + SecretRuleLoader loader = new SecretRuleLoader(); + var generator = new GitleaksRulesGenerator(config); + SecretScannerFactory factory = new SecretScannerFactory(loader, config, generator); + factory.initialize(); // manually trigger wiring outside of Spring context for the test + var executor = new org.springframework.core.task.SimpleAsyncTaskExecutor(); + return new SecretScanningService(config, factory, executor); + } + + private void setField(Object target, String name, Object value) throws Exception { + Field f = target.getClass().getDeclaredField(name); + f.setAccessible(true); + f.set(target, value); + } + + private boolean invokeRecordFinding(SecretScanningService service, + List findings, + AtomicInteger count, + SecretFinding finding) throws Exception { + Method m = SecretScanningService.class.getDeclaredMethod( + "recordFinding", List.class, AtomicInteger.class, SecretFinding.class); + m.setAccessible(true); + try { + return (boolean) m.invoke(service, findings, count, finding); + } catch (InvocationTargetException ite) { + Throwable cause = ite.getCause(); + if (cause instanceof RuntimeException re) { + throw re; + } + if (cause instanceof Error err) { + throw err; + } + throw new RuntimeException(cause); + } + } +} + diff --git a/server/src/test/resources/org/eclipse/openvsx/scanning/secret-rules-a.yaml b/server/src/test/resources/org/eclipse/openvsx/scanning/secret-rules-a.yaml new file mode 100644 index 000000000..c5af6041a --- /dev/null +++ b/server/src/test/resources/org/eclipse/openvsx/scanning/secret-rules-a.yaml @@ -0,0 +1,14 @@ +rules: + - id: rule-a + description: Base rule from file A + regex: "a[0-9]{3}" + entropy: 3.5 + keywords: + - token + - shared + allowlists: + - regexes: + - "test" + - "sample" + secretGroup: 1 + diff --git a/server/src/test/resources/org/eclipse/openvsx/scanning/secret-rules-allowlist-2.yaml b/server/src/test/resources/org/eclipse/openvsx/scanning/secret-rules-allowlist-2.yaml new file mode 100644 index 000000000..f359ff8dc --- /dev/null +++ b/server/src/test/resources/org/eclipse/openvsx/scanning/secret-rules-allowlist-2.yaml @@ -0,0 +1,21 @@ +allowlist: + paths: + - "vendor/" + - "build/" + regexes: + - "^placeholder$" + - "^dummy$" + stopwords: + - "fake" + - "mock" + file-extensions: + - ".svg" + - ".gif" + +rules: + - id: test-rule-2 + description: Second test rule with different allowlist + regex: "token[0-9]{4}" + keywords: + - token + diff --git a/server/src/test/resources/org/eclipse/openvsx/scanning/secret-rules-allowlist-3.yaml b/server/src/test/resources/org/eclipse/openvsx/scanning/secret-rules-allowlist-3.yaml new file mode 100644 index 000000000..c61de6d5c --- /dev/null +++ b/server/src/test/resources/org/eclipse/openvsx/scanning/secret-rules-allowlist-3.yaml @@ -0,0 +1,14 @@ +allowlist: + # Only paths and stopwords, to test partial allowlist merging + paths: + - "dist/" + stopwords: + - "sample" + +rules: + - id: test-rule-3 + description: Third test rule with partial allowlist + regex: "api[0-9]{3}" + keywords: + - api + diff --git a/server/src/test/resources/org/eclipse/openvsx/scanning/secret-rules-b.yaml b/server/src/test/resources/org/eclipse/openvsx/scanning/secret-rules-b.yaml new file mode 100644 index 000000000..036ffb87c --- /dev/null +++ b/server/src/test/resources/org/eclipse/openvsx/scanning/secret-rules-b.yaml @@ -0,0 +1,12 @@ +rules: + - id: rule-a + description: Override rule from file B + regex: "override[0-9]+" + keywords: + - overridekey + - id: rule-b + description: Secondary rule from file B + regex: "b[0-9]{2}" + keywords: + - key + diff --git a/server/src/test/resources/org/eclipse/openvsx/scanning/secret-rules-with-allowlist.yaml b/server/src/test/resources/org/eclipse/openvsx/scanning/secret-rules-with-allowlist.yaml new file mode 100644 index 000000000..6fceac5fb --- /dev/null +++ b/server/src/test/resources/org/eclipse/openvsx/scanning/secret-rules-with-allowlist.yaml @@ -0,0 +1,25 @@ +allowlist: + paths: + - "node_modules/" + - ".git/" + - "test/" + regexes: + - "^test$" + - "^example$" + - ".+\\.min\\.js$" + stopwords: + - "example" + - "test" + - "dummy" + file-extensions: + - ".png" + - ".jpg" + - ".zip" + +rules: + - id: test-rule-1 + description: Test rule with global allowlist + regex: "secret[0-9]{3}" + keywords: + - secret + From 8b2d49a312032315c04d235bc58bca3af1948ee9 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Wed, 7 Jan 2026 10:04:40 +0100 Subject: [PATCH 3/9] fix unit tests due to mocked secret scanning service --- .../java/org/eclipse/openvsx/RegistryAPITest.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java index f26a27677..3ee19afcb 100644 --- a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java @@ -27,6 +27,7 @@ import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; import org.eclipse.openvsx.publish.PublishExtensionVersionService; import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.scanning.SecretScanResult; import org.eclipse.openvsx.scanning.SecretScanningService; import org.eclipse.openvsx.search.*; import org.eclipse.openvsx.security.OAuth2AttributesConfig; @@ -38,6 +39,7 @@ import org.eclipse.openvsx.util.VersionAlias; import org.eclipse.openvsx.util.VersionService; import org.jobrunr.scheduling.JobRequestScheduler; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -88,8 +90,7 @@ ClientRegistrationRepository.class, UpstreamRegistryService.class, GoogleCloudStorageService.class, AzureBlobStorageService.class, AwsStorageService.class, VSCodeIdService.class, DownloadCountService.class, CacheService.class, EclipseService.class, PublishExtensionVersionService.class, SimpleMeterRegistry.class, - JobRequestScheduler.class, ExtensionControlService.class, FileCacheDurationConfig.class, CdnServiceConfig.class, - SecretScanningService.class + JobRequestScheduler.class, ExtensionControlService.class, FileCacheDurationConfig.class, CdnServiceConfig.class }) class RegistryAPITest { @@ -108,12 +109,20 @@ class RegistryAPITest { @MockitoBean EntityManager entityManager; + @MockitoBean + SecretScanningService secretScanningService; + @Autowired MockMvc mockMvc; @Autowired ExtensionService extensions; + @BeforeEach + void setup() { + Mockito.when(secretScanningService.scanForSecrets(any())).thenReturn(SecretScanResult.skipped()); + } + @Test void testPublicNamespace() throws Exception { var namespace = mockNamespace(); From 757411e095292544ebac6e47dde53bbb4cd1c284 Mon Sep 17 00:00:00 2001 From: Alejandro Munoz Date: Wed, 7 Jan 2026 14:29:44 -0500 Subject: [PATCH 4/9] Refactor validation services (#1531) - Add external isEnabled for secret scanning and similarity services - Rename secret scanner config - Refactor secret scanner util - Fix secret scanner config dev example --- server/src/dev/resources/application.yml | 2 +- .../org/eclipse/openvsx/ExtensionService.java | 44 ++++++------ .../eclipse/openvsx/LocalRegistryService.java | 24 ++++--- .../PublishExtensionVersionHandler.java | 5 ++ .../scanning/GitleaksRulesGenerator.java | 6 +- .../openvsx/scanning/SecretScanner.java | 39 +---------- .../scanning/SecretScannerFactory.java | 14 ++-- ...uration.java => SecretScanningConfig.java} | 7 +- .../scanning/SecretScanningService.java | 21 +++--- .../search/SimilarityCheckService.java | 67 ++++++++++--------- .../openvsx/search/SimilarityService.java | 5 +- .../openvsx/util/SizeLimitInputStream.java | 58 ++++++++++++++++ .../openvsx/LocalRegistryServiceTest.java | 3 +- .../PublishExtensionVersionHandlerTest.java | 4 ++ .../scanning/GitleaksRulesGeneratorTest.java | 26 +++---- .../scanning/SecretScannerFactoryTest.java | 12 ++-- .../scanning/SecretScanningServiceTest.java | 2 +- .../search/SimilarityCheckServiceTest.java | 45 +++---------- 18 files changed, 197 insertions(+), 187 deletions(-) rename server/src/main/java/org/eclipse/openvsx/scanning/{SecretScanningConfiguration.java => SecretScanningConfig.java} (94%) create mode 100644 server/src/main/java/org/eclipse/openvsx/util/SizeLimitInputStream.java diff --git a/server/src/dev/resources/application.yml b/server/src/dev/resources/application.yml index b8d2b5f7c..3d483a2e1 100644 --- a/server/src/dev/resources/application.yml +++ b/server/src/dev/resources/application.yml @@ -191,6 +191,6 @@ ovsx: max-line-length: 10000 long-line-no-space-threshold: 1000 keyword-context-chars: 100 - log-secret-preview-chars: 10 + log-allowlisted-value-preview-length: 10 timeout-seconds: 5 timeout-check-every-n-lines: 100 diff --git a/server/src/main/java/org/eclipse/openvsx/ExtensionService.java b/server/src/main/java/org/eclipse/openvsx/ExtensionService.java index fb39f02bf..67477374e 100644 --- a/server/src/main/java/org/eclipse/openvsx/ExtensionService.java +++ b/server/src/main/java/org/eclipse/openvsx/ExtensionService.java @@ -101,28 +101,30 @@ public ExtensionVersion publishVersion(InputStream content, PersonalAccessToken private void doPublish(TempFile extensionFile, String binaryName, PersonalAccessToken token, LocalDateTime timestamp, boolean checkDependencies) { // Scan for secrets before processing the extension // This fails fast if secrets are detected, preventing publication - var scanResult = secretScanningService.scanForSecrets(extensionFile); - if (scanResult.isSecretsFound()) { - var findings = scanResult.getFindings(); - var errorMessage = new StringBuilder(); - errorMessage.append("Extension publication blocked: potential secrets detected in the package.\n\n"); - errorMessage.append("The following potential secrets were found:\n"); - - int maxFindings = Math.min(5, findings.size()); - for (int i = 0; i < maxFindings; i++) { - errorMessage.append(" ").append(i + 1).append(". ").append(findings.get(i).toString()).append("\n"); - } - - if (findings.size() > maxFindings) { - errorMessage.append(" ... and ").append(findings.size() - maxFindings).append(" more\n"); + if (secretScanningService.isEnabled()) { + var scanResult = secretScanningService.scanForSecrets(extensionFile); + if (scanResult.isSecretsFound()) { + var findings = scanResult.getFindings(); + var errorMessage = new StringBuilder(); + errorMessage.append("Extension publication blocked: potential secrets detected in the package.\n\n"); + errorMessage.append("The following potential secrets were found:\n"); + + int maxFindings = Math.min(5, findings.size()); + for (int i = 0; i < maxFindings; i++) { + errorMessage.append(" ").append(i + 1).append(". ").append(findings.get(i).toString()).append("\n"); + } + + if (findings.size() > maxFindings) { + errorMessage.append(" ... and ").append(findings.size() - maxFindings).append(" more\n"); + } + + errorMessage.append("\nPlease remove these secrets before publishing. "); + errorMessage.append("Consider using environment variables or configuration files that are not included in the package. "); + + errorMessage.append("Refer to the publishing guidelines: https://github.com/EclipseFdn/open-vsx.org/wiki/Publishing-Extensions"); + + throw new ErrorResultException(errorMessage.toString()); } - - errorMessage.append("\nPlease remove these secrets before publishing. "); - errorMessage.append("Consider using environment variables or configuration files that are not included in the package. "); - - errorMessage.append("Refer to the publishing guidelines: https://github.com/EclipseFdn/open-vsx.org/wiki/Publishing-Extensions"); - - throw new ErrorResultException(errorMessage.toString()); } try (var processor = new ExtensionProcessor(extensionFile)) { diff --git a/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java b/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java index 13cc8976c..04c0893bc 100644 --- a/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java +++ b/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java @@ -599,17 +599,19 @@ public ResultJson createNamespace(NamespaceJson json, UserData user) { throw new ErrorResultException("Namespace already exists: " + namespaceName); } - // Check if the proposed namespace name is too similar to existing ones - var similarNamespaces = similarityCheckService.findSimilarNamespacesForCreation(json.getName(), user); - if (!similarNamespaces.isEmpty()) { - var similarNames = similarNamespaces.stream() - .map(Namespace::getName) - .collect(Collectors.joining(", ")); - throw new ErrorResultException( - "Namespace name '" + json.getName() + "' is too similar to existing namespace(s): " + similarNames + ". " + - "Please choose a more distinct name to avoid confusion. " + - "Refer to the publishing guidelines: https://github.com/EclipseFdn/open-vsx.org/wiki/Publishing-Extensions" - ); + // Check if the proposed namespace name is too similar to existing ones (if enabled) + if (similarityCheckService.isEnabled()) { + var similarNamespaces = similarityCheckService.findSimilarNamespacesForCreation(json.getName(), user); + if (!similarNamespaces.isEmpty()) { + var similarNames = similarNamespaces.stream() + .map(Namespace::getName) + .collect(Collectors.joining(", ")); + throw new ErrorResultException( + "Namespace name '" + json.getName() + "' is too similar to existing namespace(s): " + similarNames + ". " + + "Please choose a more distinct name to avoid confusion. " + + "Refer to the publishing guidelines: https://github.com/EclipseFdn/open-vsx.org/wiki/Publishing-Extensions" + ); + } } // Create the requested namespace diff --git a/server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java b/server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java index ca38f6c1b..96576a55e 100644 --- a/server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java +++ b/server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java @@ -175,6 +175,11 @@ private void validateExtensionName(String namespaceName, String extensionName, S } private void validateDistinctName(String extensionName, String namespaceName, String displayName, UserData user) { + // Check if similarity checking is enabled before invoking + if (!similarityCheckService.isEnabled()) { + return; + } + // Use SimilarityCheckService which handles config gates and "exclude owner namespaces" logic var similarExtensions = similarityCheckService.findSimilarExtensionsForPublishing( extensionName, diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/GitleaksRulesGenerator.java b/server/src/main/java/org/eclipse/openvsx/scanning/GitleaksRulesGenerator.java index a63596cd0..f72fefad6 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/GitleaksRulesGenerator.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/GitleaksRulesGenerator.java @@ -18,8 +18,6 @@ import jakarta.annotation.PostConstruct; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import java.io.File; @@ -61,7 +59,7 @@ public class GitleaksRulesGenerator { */ private static final Set SKIP_RULE_IDS = Set.of("generic-api-key"); - private final SecretScanningConfiguration config; + private final SecretScanningConfig config; /** * Path to the generated rules file, if generation succeeded. @@ -69,7 +67,7 @@ public class GitleaksRulesGenerator { */ private String generatedRulesPath; - public GitleaksRulesGenerator(SecretScanningConfiguration config) { + public GitleaksRulesGenerator(SecretScanningConfig config) { this.config = config; } diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanner.java b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanner.java index f49908398..7b430fa55 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanner.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanner.java @@ -16,6 +16,7 @@ import org.slf4j.LoggerFactory; import org.apache.commons.lang3.StringUtils; import org.eclipse.openvsx.util.ArchiveUtil; +import org.eclipse.openvsx.util.SizeLimitInputStream; import jakarta.validation.constraints.NotNull; import javax.annotation.Nullable; @@ -360,43 +361,5 @@ private boolean isAllowlistedContent(@NotNull String secretValue) { return false; } - - /** - * InputStream wrapper that enforces a hard byte limit to prevent OOM from misleading entry headers. - */ - private static class SizeLimitInputStream extends java.io.FilterInputStream { - private final long maxBytes; - private long bytesRead = 0; - - protected SizeLimitInputStream(InputStream in, long maxBytes) { - super(in); - this.maxBytes = maxBytes; - } - - @Override - public int read() throws IOException { - int b = super.read(); - if (b != -1) { - checkLimit(1); - } - return b; - } - - @Override - public int read(byte[] b, int off, int len) throws IOException { - int n = super.read(b, off, len); - if (n != -1) { - checkLimit(n); - } - return n; - } - - private void checkLimit(long n) throws IOException { - bytesRead += n; - if (bytesRead > maxBytes) { - throw new IOException("File size exceeds limit of " + maxBytes + " bytes"); - } - } - } } diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/SecretScannerFactory.java b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScannerFactory.java index 762af7da9..d398c4a4d 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/SecretScannerFactory.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScannerFactory.java @@ -46,7 +46,7 @@ public class SecretScannerFactory { private static final Logger logger = LoggerFactory.getLogger(SecretScannerFactory.class); private final SecretRuleLoader ruleLoader; - private final SecretScanningConfiguration config; + private final SecretScanningConfig config; private final GitleaksRulesGenerator generator; private List rules = List.of(); @@ -56,7 +56,7 @@ public class SecretScannerFactory { public SecretScannerFactory( @NotNull SecretRuleLoader ruleLoader, - @NotNull SecretScanningConfiguration config, + @NotNull SecretScanningConfig config, @NotNull GitleaksRulesGenerator generator) { this.ruleLoader = ruleLoader; this.config = config; @@ -191,7 +191,7 @@ private AhoCorasick buildMatcher(java.util.Collection keywords) { */ private List getGlobalExcludedExtensions( @Nullable SecretRuleLoader.GlobalAllowlist globalAllowlist, - @NotNull SecretScanningConfiguration config) { + @NotNull SecretScanningConfig config) { List result = new ArrayList<>(); if (globalAllowlist != null && globalAllowlist.fileExtensions != null) { @@ -210,7 +210,7 @@ private List getGlobalExcludedExtensions( */ private List getGlobalExcludedPathPatterns( @Nullable SecretRuleLoader.GlobalAllowlist globalAllowlist, - @NotNull SecretScanningConfiguration config) { + @NotNull SecretScanningConfig config) { List result = new ArrayList<>(); if (globalAllowlist != null && globalAllowlist.paths != null) { @@ -228,7 +228,7 @@ private List getGlobalExcludedPathPatterns( */ private List getGlobalAllowlistPatterns( @Nullable SecretRuleLoader.GlobalAllowlist globalAllowlist, - @NotNull SecretScanningConfiguration config) { + @NotNull SecretScanningConfig config) { List result = new ArrayList<>(); if (globalAllowlist != null && globalAllowlist.regexes != null && !globalAllowlist.regexes.isEmpty()) { @@ -246,7 +246,7 @@ private List getGlobalAllowlistPatterns( */ private List getGlobalStopwords( @Nullable SecretRuleLoader.GlobalAllowlist globalAllowlist, - @NotNull SecretScanningConfiguration config) { + @NotNull SecretScanningConfig config) { List result = new ArrayList<>(); if (globalAllowlist != null && globalAllowlist.stopwords != null) { @@ -262,7 +262,7 @@ private List getGlobalStopwords( * Get inline suppression markers from config. * Returns lowercase versions of the suppression markers for case-insensitive matching. */ - private List getInlineSuppressions(@NotNull SecretScanningConfiguration config) { + private List getInlineSuppressions(@NotNull SecretScanningConfig config) { return config.getInlineSuppressions().stream() .map(String::toLowerCase) .toList(); diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningConfiguration.java b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningConfig.java similarity index 94% rename from server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningConfiguration.java rename to server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningConfig.java index e6620e867..3742f2464 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningConfiguration.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningConfig.java @@ -12,7 +12,6 @@ ********************************************************************************/ package org.eclipse.openvsx.scanning; -import com.google.re2j.Pattern; import jakarta.annotation.PostConstruct; import jakarta.validation.constraints.NotNull; import org.springframework.beans.factory.annotation.Value; @@ -28,7 +27,7 @@ * before allowing publication. It uses regex patterns and entropy calculation to detect secrets. */ @Configuration -public class SecretScanningConfiguration { +public class SecretScanningConfig { /** * Enables or disables secret scanning for extension publishing. @@ -185,7 +184,7 @@ public class SecretScanningConfiguration { /** * Characters to include when logging secret preview values (for debugging allowlisted secrets). * - * Property: {@code ovsx.secret-scanning.log-secret-preview-chars} + * Property: {@code ovsx.secret-scanning.log-allowlisted-value-preview-length} * Default: {@code 10} */ @Value("${ovsx.secret-scanning.log-allowlisted-value-preview-length:10}") @@ -330,7 +329,7 @@ public void validate() { if (logAllowlistedPreviewLength < 0) { throw new IllegalArgumentException( - "ovsx.secret-scanning.log-secret-preview-chars must be >= 0, got: " + logAllowlistedPreviewLength); + "ovsx.secret-scanning.log-allowlisted-value-preview-length must be >= 0, got: " + logAllowlistedPreviewLength); } } } diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningService.java b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningService.java index 6ae7c60aa..3237f913e 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningService.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningService.java @@ -26,7 +26,6 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicInteger; @@ -46,7 +45,7 @@ public class SecretScanningService { private static final Logger logger = LoggerFactory.getLogger(SecretScanningService.class); - private final SecretScanningConfiguration config; + private final SecretScanningConfig config; private final SecretScanner fileContentScanner; private final AsyncTaskExecutor taskExecutor; @@ -65,7 +64,7 @@ static class ScanCancelledException extends RuntimeException { * Constructs a secret scanning service with the specified configuration and executor. */ public SecretScanningService( - SecretScanningConfiguration config, + SecretScanningConfig config, SecretScannerFactory scannerFactory, AsyncTaskExecutor taskExecutor) { this.config = config; @@ -79,19 +78,19 @@ public SecretScanningService( this.fileContentScanner = scannerFactory.getScanner(); } + /** + * Returns whether secret scanning is enabled. + */ + public boolean isEnabled() { + return config.isEnabled(); + } + /** * Scans an extension package for potential secrets. * - * This method checks if secret scanning is enabled for the publishing flow. - * When disabled, extensions can still be published without secret detection. - * The scanner itself is always available for other use cases (e.g., retroactive scans). + * Callers should check {@link #isEnabled()} before invoking this method. */ public SecretScanResult scanForSecrets(@NotNull TempFile extensionFile) { - if (!config.isEnabled()) { - logger.debug("Secret scanning is disabled for publishing"); - return SecretScanResult.skipped(); - } - // Use thread-safe collection for parallel processing List findings = Collections.synchronizedList(new ArrayList<>()); AtomicInteger findingsCount = new AtomicInteger(0); // Cap findings to protect memory diff --git a/server/src/main/java/org/eclipse/openvsx/search/SimilarityCheckService.java b/server/src/main/java/org/eclipse/openvsx/search/SimilarityCheckService.java index 8f4d2c731..a49cb5ae3 100644 --- a/server/src/main/java/org/eclipse/openvsx/search/SimilarityCheckService.java +++ b/server/src/main/java/org/eclipse/openvsx/search/SimilarityCheckService.java @@ -12,6 +12,7 @@ ********************************************************************************/ package org.eclipse.openvsx.search; +import jakarta.validation.constraints.NotNull; import org.eclipse.openvsx.entities.Extension; import org.eclipse.openvsx.entities.Namespace; import org.eclipse.openvsx.entities.NamespaceMembership; @@ -19,6 +20,7 @@ import org.eclipse.openvsx.repositories.RepositoryService; import org.springframework.stereotype.Service; +import javax.annotation.Nullable; import java.util.List; /** @@ -45,19 +47,23 @@ public SimilarityCheckService( this.repositories = repositories; } + /** + * Returns whether similarity checking is enabled. + */ + public boolean isEnabled() { + return config.isEnabled(); + } + /** * Enforce configured similarity rules for publishing an extension. + * Callers should check {@link #isEnabled()} before invoking this method. */ public List findSimilarExtensionsForPublishing( - String extensionName, - String namespaceName, - String displayName, - UserData publishingUser + @Nullable String extensionName, + @Nullable String namespaceName, + @Nullable String displayName, + @NotNull UserData publishingUser ) { - if (!config.isEnabled()) { - return List.of(); - } - if (config.isNewExtensionsOnly() && namespaceName != null && extensionName != null) { if (repositories.countVersions(namespaceName, extensionName) > 0) { return List.of(); @@ -71,19 +77,11 @@ public List findSimilarExtensionsForPublishing( } } - List excludeNamespaces = config.isExcludeOwnerNamespaces() - ? repositories.findMemberships(publishingUser) - .stream() - .filter(membership -> NamespaceMembership.ROLE_OWNER.equals(membership.getRole())) - .map(membership -> membership.getNamespace().getName()) - .toList() - : List.of(); - return similarityService.findSimilarExtensions( extensionName, namespaceName, displayName, - excludeNamespaces, + getExcludedNamespaces(publishingUser), config.getLevenshteinThreshold(), config.isCheckAgainstVerifiedOnly(), LIMIT @@ -92,28 +90,35 @@ public List findSimilarExtensionsForPublishing( /** * Enforce configured similarity rules for namespace creation. + * Callers should check {@link #isEnabled()} before invoking this method. */ - public List findSimilarNamespacesForCreation(String namespaceName, UserData publishingUser) { - if (!config.isEnabled()) { - return List.of(); - } - - List excludeNamespaces = config.isExcludeOwnerNamespaces() - ? repositories.findMemberships(publishingUser) - .stream() - .filter(membership -> NamespaceMembership.ROLE_OWNER.equals(membership.getRole())) - .map(membership -> membership.getNamespace().getName()) - .toList() - : List.of(); - + public List findSimilarNamespacesForCreation( + @NotNull String namespaceName, + @NotNull UserData publishingUser + ) { return similarityService.findSimilarNamespaces( namespaceName, - excludeNamespaces, + getExcludedNamespaces(publishingUser), config.getLevenshteinThreshold(), config.isCheckAgainstVerifiedOnly(), LIMIT ); } + + /** + * Get the list of namespaces to exclude from similarity checks. + * When configured, excludes namespaces where the user is an owner. + */ + private List getExcludedNamespaces(@NotNull UserData user) { + if (!config.isExcludeOwnerNamespaces()) { + return List.of(); + } + return repositories.findMemberships(user) + .stream() + .filter(m -> NamespaceMembership.ROLE_OWNER.equals(m.getRole())) + .map(m -> m.getNamespace().getName()) + .toList(); + } } diff --git a/server/src/main/java/org/eclipse/openvsx/search/SimilarityService.java b/server/src/main/java/org/eclipse/openvsx/search/SimilarityService.java index 58cd3f069..05592e609 100644 --- a/server/src/main/java/org/eclipse/openvsx/search/SimilarityService.java +++ b/server/src/main/java/org/eclipse/openvsx/search/SimilarityService.java @@ -16,6 +16,7 @@ import org.eclipse.openvsx.entities.Extension; import org.eclipse.openvsx.entities.Namespace; import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.util.ErrorResultException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; @@ -63,7 +64,7 @@ public List findSimilarExtensions( logger.error("Similarity check failed for extension='{}', namespace='{}', displayName='{}': {}", extensionName, namespaceName, displayName, e.getMessage(), e); - throw new RuntimeException( + throw new ErrorResultException( "Unable to verify extension name uniqueness due to system error. " + "Please try again later or contact support if the problem persists." ); @@ -97,7 +98,7 @@ public List findSimilarNamespaces( logger.error("Similarity check failed for namespace='{}': {}", namespaceName, e.getMessage(), e); - throw new RuntimeException( + throw new ErrorResultException( "Unable to verify namespace name uniqueness due to system error. " + "Please try again later or contact support if the problem persists." ); diff --git a/server/src/main/java/org/eclipse/openvsx/util/SizeLimitInputStream.java b/server/src/main/java/org/eclipse/openvsx/util/SizeLimitInputStream.java new file mode 100644 index 000000000..1ab4b4c7d --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/util/SizeLimitInputStream.java @@ -0,0 +1,58 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.util; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * InputStream wrapper that enforces a hard byte limit. + * Useful for preventing OOM from misleading archive entry headers. + */ +public class SizeLimitInputStream extends FilterInputStream { + + private final long maxBytes; + private long bytesRead = 0; + + public SizeLimitInputStream(InputStream in, long maxBytes) { + super(in); + this.maxBytes = maxBytes; + } + + @Override + public int read() throws IOException { + int b = super.read(); + if (b != -1) { + checkLimit(1); + } + return b; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int n = super.read(b, off, len); + if (n != -1) { + checkLimit(n); + } + return n; + } + + private void checkLimit(long n) throws IOException { + bytesRead += n; + if (bytesRead > maxBytes) { + throw new IOException("File size exceeds limit of " + maxBytes + " bytes"); + } + } +} + diff --git a/server/src/test/java/org/eclipse/openvsx/LocalRegistryServiceTest.java b/server/src/test/java/org/eclipse/openvsx/LocalRegistryServiceTest.java index 76c4a505f..10cffed5c 100644 --- a/server/src/test/java/org/eclipse/openvsx/LocalRegistryServiceTest.java +++ b/server/src/test/java/org/eclipse/openvsx/LocalRegistryServiceTest.java @@ -32,7 +32,6 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.util.Streamable; import java.util.List; import java.util.Optional; @@ -115,6 +114,7 @@ void shouldRejectNamespaceWhenSimilarNameExists() { when(validator.validateNamespace("new-space")).thenReturn(Optional.empty()); when(repositories.findNamespaceName("new-space")).thenReturn(null); + when(similarityCheckService.isEnabled()).thenReturn(true); when(similarityCheckService.findSimilarNamespacesForCreation("new-space", user)) .thenReturn(List.of(buildNamespace("new-space-1"))); @@ -153,6 +153,7 @@ void shouldCreateNamespaceAndAssignContributorRole() { when(validator.validateNamespace("clean-ns")).thenReturn(Optional.empty()); when(repositories.findNamespaceName("clean-ns")).thenReturn(null); + when(similarityCheckService.isEnabled()).thenReturn(true); when(similarityCheckService.findSimilarNamespacesForCreation("clean-ns", user)).thenReturn(List.of()); registryService.createNamespace(json, user); diff --git a/server/src/test/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandlerTest.java b/server/src/test/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandlerTest.java index ef1f1036b..0a320ed9f 100644 --- a/server/src/test/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandlerTest.java +++ b/server/src/test/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandlerTest.java @@ -122,6 +122,7 @@ void shouldFailPublishingWhenSimilarExtensionAlreadyExists() { var similarExtension = new Extension(); similarExtension.setNamespace(buildNamespace("other")); similarExtension.setName("demo-other"); + when(similarityCheckService.isEnabled()).thenReturn(true); when(similarityCheckService.findSimilarExtensionsForPublishing("demo", "publisher", "Demo Extension", user)) .thenReturn(List.of(similarExtension)); @@ -165,6 +166,7 @@ void shouldExcludeOwnedNamespacesFromSimilarityCheck() { when(validator.validateExtensionVersion("1.0.1")).thenReturn(Optional.empty()); when(validator.validateExtensionName("demo")).thenReturn(Optional.empty()); when(validator.validateMetadata(metadata)).thenReturn(List.of()); + when(similarityCheckService.isEnabled()).thenReturn(true); when(similarityCheckService.findSimilarExtensionsForPublishing("demo", "publisher", "Demo Next", user)) .thenReturn(List.of()); when(repositories.findExtension("demo", namespace)).thenReturn(null); @@ -200,6 +202,7 @@ void shouldCreateExtensionWhenSimilarityFindsNoConflicts() { when(validator.validateExtensionVersion("2.0.0")).thenReturn(Optional.empty()); when(validator.validateExtensionName("demo")).thenReturn(Optional.empty()); when(validator.validateMetadata(metadata)).thenReturn(List.of()); + when(similarityCheckService.isEnabled()).thenReturn(true); when(similarityCheckService.findSimilarExtensionsForPublishing("demo", "publisher", "Demo OK", user)) .thenReturn(List.of()); when(repositories.findExtension("demo", namespace)).thenReturn(null); @@ -235,6 +238,7 @@ void shouldCheckSimilarityForAllExtensions() { when(validator.validateExtensionVersion("3.0.0")).thenReturn(Optional.empty()); when(validator.validateExtensionName("demo")).thenReturn(Optional.empty()); when(validator.validateMetadata(processor.getMetadata())).thenReturn(List.of()); + when(similarityCheckService.isEnabled()).thenReturn(true); when(similarityCheckService.findSimilarExtensionsForPublishing("demo", "pub", null, user)).thenReturn(List.of()); when(repositories.findExtension("demo", namespace)).thenReturn(null); diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/GitleaksRulesGeneratorTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/GitleaksRulesGeneratorTest.java index 324a72994..19b34368e 100644 --- a/server/src/test/java/org/eclipse/openvsx/scanning/GitleaksRulesGeneratorTest.java +++ b/server/src/test/java/org/eclipse/openvsx/scanning/GitleaksRulesGeneratorTest.java @@ -36,7 +36,7 @@ class GitleaksRulesGeneratorTest { @Test void skipsGenerationWhenDisabled() { - SecretScanningConfiguration config = buildConfig(false, false, null); + SecretScanningConfig config = buildConfig(false, false, null); GitleaksRulesGenerator generator = new GitleaksRulesGenerator(config); // Should not throw @@ -48,7 +48,7 @@ void skipsGenerationWhenDisabled() { @Test void skipsGenerationWhenAutoGenerateDisabled() { - SecretScanningConfiguration config = buildConfig(true, false, tempDir.resolve("rules.yaml").toString()); + SecretScanningConfig config = buildConfig(true, false, tempDir.resolve("rules.yaml").toString()); GitleaksRulesGenerator generator = new GitleaksRulesGenerator(config); // Should not throw @@ -60,7 +60,7 @@ void skipsGenerationWhenAutoGenerateDisabled() { @Test void failsWhenAutoGenerateEnabledButPathNotConfigured() { - SecretScanningConfiguration config = buildConfig(true, true, ""); // Empty path + SecretScanningConfig config = buildConfig(true, true, ""); // Empty path GitleaksRulesGenerator generator = new GitleaksRulesGenerator(config); IllegalStateException exception = assertThrows( @@ -80,7 +80,7 @@ void failsWhenAutoGenerateEnabledButPathNotConfigured() { @Test void failsWhenAutoGenerateEnabledButPathIsNull() { - SecretScanningConfiguration config = buildConfig(true, true, null); // Null path + SecretScanningConfig config = buildConfig(true, true, null); // Null path GitleaksRulesGenerator generator = new GitleaksRulesGenerator(config); IllegalStateException exception = assertThrows( @@ -100,7 +100,7 @@ void createsParentDirectoryIfNotExists() throws Exception { Path outputFile = tempDir.resolve("subdir1/subdir2/rules.yaml"); assertFalse(Files.exists(outputFile.getParent()), "Parent directory should not exist yet"); - SecretScanningConfiguration config = buildConfig(true, true, outputFile.toString()); + SecretScanningConfig config = buildConfig(true, true, outputFile.toString()); GitleaksRulesGenerator generator = new GitleaksRulesGenerator(config); // Call resolveOutputFile via reflection @@ -126,7 +126,7 @@ void failsWhenParentDirectoryCannotBeCreated() throws Exception { // Now try to create a file under it - this should fail Path impossiblePath = tempDir.resolve("subdir/content/rules.yaml"); - SecretScanningConfiguration config = buildConfig(true, true, impossiblePath.toString()); + SecretScanningConfig config = buildConfig(true, true, impossiblePath.toString()); GitleaksRulesGenerator generator = new GitleaksRulesGenerator(config); IllegalStateException exception = assertThrows( @@ -148,7 +148,7 @@ void skipsGenerationWhenFileExistsAndNoForceRegenerate() throws Exception { Files.writeString(outputFile, "existing content"); long originalSize = Files.size(outputFile); - SecretScanningConfiguration config = buildConfig(true, true, outputFile.toString()); + SecretScanningConfig config = buildConfig(true, true, outputFile.toString()); setField(config, "forceRegenerateRules", false); GitleaksRulesGenerator generator = new GitleaksRulesGenerator(config); @@ -167,7 +167,7 @@ void storesGeneratedPathForLaterRetrieval() throws Exception { Path outputFile = tempDir.resolve("rules.yaml"); Files.writeString(outputFile, "test"); // Pre-create to skip actual generation - SecretScanningConfiguration config = buildConfig(true, true, outputFile.toString()); + SecretScanningConfig config = buildConfig(true, true, outputFile.toString()); setField(config, "forceRegenerateRules", false); GitleaksRulesGenerator generator = new GitleaksRulesGenerator(config); @@ -181,7 +181,7 @@ void storesGeneratedPathForLaterRetrieval() throws Exception { @Test void resolveOutputFileReturnsNullWhenPathNotConfigured() throws Exception { - SecretScanningConfiguration config = buildConfig(true, true, ""); + SecretScanningConfig config = buildConfig(true, true, ""); GitleaksRulesGenerator generator = new GitleaksRulesGenerator(config); Method resolveMethod = GitleaksRulesGenerator.class.getDeclaredMethod("resolveOutputFile"); @@ -204,7 +204,7 @@ void resolveOutputFileReturnsNullWhenPathNotConfigured() throws Exception { void acceptsAbsolutePath() throws Exception { Path outputFile = tempDir.resolve("absolute-path-rules.yaml").toAbsolutePath(); - SecretScanningConfiguration config = buildConfig(true, true, outputFile.toString()); + SecretScanningConfig config = buildConfig(true, true, outputFile.toString()); GitleaksRulesGenerator generator = new GitleaksRulesGenerator(config); Method resolveMethod = GitleaksRulesGenerator.class.getDeclaredMethod("resolveOutputFile"); @@ -218,7 +218,7 @@ void acceptsAbsolutePath() throws Exception { void acceptsRelativePath() throws Exception { String relativePath = "relative/path/rules.yaml"; - SecretScanningConfiguration config = buildConfig(true, true, relativePath); + SecretScanningConfig config = buildConfig(true, true, relativePath); GitleaksRulesGenerator generator = new GitleaksRulesGenerator(config); Method resolveMethod = GitleaksRulesGenerator.class.getDeclaredMethod("resolveOutputFile"); @@ -231,8 +231,8 @@ void acceptsRelativePath() throws Exception { // Helper methods - private SecretScanningConfiguration buildConfig(boolean enabled, boolean autoGenerate, String path) { - SecretScanningConfiguration config = new SecretScanningConfiguration(); + private SecretScanningConfig buildConfig(boolean enabled, boolean autoGenerate, String path) { + SecretScanningConfig config = new SecretScanningConfig(); try { setField(config, "enabled", enabled); setField(config, "autoGenerateRules", autoGenerate); diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/SecretScannerFactoryTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/SecretScannerFactoryTest.java index ce4028e2b..8294d12ef 100644 --- a/server/src/test/java/org/eclipse/openvsx/scanning/SecretScannerFactoryTest.java +++ b/server/src/test/java/org/eclipse/openvsx/scanning/SecretScannerFactoryTest.java @@ -29,7 +29,7 @@ class SecretScannerFactoryTest { void initialize_buildsEvenWhenDisabled() throws Exception { // Factory initializes if rule paths are configured, even when publishing-time scanning is disabled TrackingRuleLoader loader = new TrackingRuleLoader(); - SecretScanningConfiguration config = buildConfig(false); // disabled + SecretScanningConfig config = buildConfig(false); // disabled setField(config, "rulesPath", "classpath:org/eclipse/openvsx/scanning/secret-rules-a.yaml"); // Use test resource MockGitleaksRulesGenerator generator = new MockGitleaksRulesGenerator(null); SecretScannerFactory factory = new SecretScannerFactory(loader, config, generator); @@ -46,7 +46,7 @@ void initialize_buildsEvenWhenDisabled() throws Exception { void initialize_skipsWhenNotConfigured() throws Exception { // Factory skips initialization if ovsx.secret-scanning block is not present TrackingRuleLoader loader = new TrackingRuleLoader(); - SecretScanningConfiguration config = buildConfig(false); // disabled + SecretScanningConfig config = buildConfig(false); // disabled MockGitleaksRulesGenerator generator = new MockGitleaksRulesGenerator(null); SecretScannerFactory factory = new SecretScannerFactory(loader, config, generator); @@ -61,7 +61,7 @@ void initialize_skipsWhenNotConfigured() throws Exception { @Test void initialize_buildsMatchersAndIndexes() throws Exception { TrackingRuleLoader loader = new TrackingRuleLoader(); - SecretScanningConfiguration config = buildConfig(true); + SecretScanningConfig config = buildConfig(true); setField(config, "rulesPath", "classpath:org/eclipse/openvsx/scanning/secret-rules-a.yaml," + "classpath:org/eclipse/openvsx/scanning/secret-rules-b.yaml"); @@ -88,7 +88,7 @@ void initialize_buildsMatchersAndIndexes() throws Exception { @Test void initialize_loadsGlobalAllowlistFromYaml() throws Exception { SecretRuleLoader loader = new SecretRuleLoader(); - SecretScanningConfiguration config = buildConfig(true); + SecretScanningConfig config = buildConfig(true); setField(config, "rulesPath", "classpath:org/eclipse/openvsx/scanning/secret-rules-with-allowlist.yaml"); MockGitleaksRulesGenerator generator = new MockGitleaksRulesGenerator(null); @@ -103,9 +103,9 @@ void initialize_loadsGlobalAllowlistFromYaml() throws Exception { assertNotNull(factory.getScanner()); } - private SecretScanningConfiguration buildConfig(boolean enabled) throws Exception { + private SecretScanningConfig buildConfig(boolean enabled) throws Exception { // We set all the primitive fields explicitly so the factory can use getters safely. - SecretScanningConfiguration config = new SecretScanningConfiguration(); + SecretScanningConfig config = new SecretScanningConfig(); setField(config, "enabled", enabled); setField(config, "maxFileSizeBytes", 1024 * 1024); setField(config, "maxLineLength", 10_000); diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/SecretScanningServiceTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/SecretScanningServiceTest.java index e5036e2c8..f1d3dcb93 100644 --- a/server/src/test/java/org/eclipse/openvsx/scanning/SecretScanningServiceTest.java +++ b/server/src/test/java/org/eclipse/openvsx/scanning/SecretScanningServiceTest.java @@ -90,7 +90,7 @@ void loadRules_failsFastWhenMissing() { // --- Helpers ---------------------------------------------------------------- private SecretScanningService buildServiceWithLimits(int maxEntries, long maxBytes, int maxFindings) throws Exception { - SecretScanningConfiguration config = new SecretScanningConfiguration(); + SecretScanningConfig config = new SecretScanningConfig(); setField(config, "enabled", true); setField(config, "maxFileSizeBytes", 1024 * 1024); setField(config, "maxLineLength", 10_000); diff --git a/server/src/test/java/org/eclipse/openvsx/search/SimilarityCheckServiceTest.java b/server/src/test/java/org/eclipse/openvsx/search/SimilarityCheckServiceTest.java index 4940f58c6..db6f84419 100644 --- a/server/src/test/java/org/eclipse/openvsx/search/SimilarityCheckServiceTest.java +++ b/server/src/test/java/org/eclipse/openvsx/search/SimilarityCheckServiceTest.java @@ -57,23 +57,17 @@ void setUp() { } @Test - void shouldReturnEmptyWhenSimilarityChecksDisabled() { - // When the feature is disabled, no database work should happen. - when(config.isEnabled()).thenReturn(false); - - var result = similarityCheckService.findSimilarExtensionsForPublishing( - "ext", "ns", "Display", user - ); + void isEnabled_shouldDelegateToConfig() { + when(config.isEnabled()).thenReturn(true); + assertThat(similarityCheckService.isEnabled()).isTrue(); - assertThat(result).isEmpty(); - verifyNoInteractions(similarityService); - verifyNoInteractions(repositories); + when(config.isEnabled()).thenReturn(false); + assertThat(similarityCheckService.isEnabled()).isFalse(); } @Test void shouldExcludeOwnerNamespacesWhenConfigured() { // When exclude-owner-namespaces is enabled, we should build a list of owner namespaces to exclude. - when(config.isEnabled()).thenReturn(true); when(config.isExcludeOwnerNamespaces()).thenReturn(true); when(config.getLevenshteinThreshold()).thenReturn(0.15); when(config.isCheckAgainstVerifiedOnly()).thenReturn(false); @@ -106,7 +100,6 @@ void shouldExcludeOwnerNamespacesWhenConfigured() { @Test void shouldNotExcludeNamespacesWhenConfigDisabled() { // When exclude-owner-namespaces is disabled, pass an empty exclude list. - when(config.isEnabled()).thenReturn(true); when(config.isExcludeOwnerNamespaces()).thenReturn(false); when(config.getLevenshteinThreshold()).thenReturn(0.15); when(config.isCheckAgainstVerifiedOnly()).thenReturn(false); @@ -123,9 +116,8 @@ void shouldNotExcludeNamespacesWhenConfigDisabled() { } @Test - void shouldDelegateSimilarExtensionsToServiceWhenEnabled() { - // Happy path: when enabled, delegate to SimilarityService with proper parameters. - when(config.isEnabled()).thenReturn(true); + void shouldDelegateSimilarExtensionsToService() { + // Happy path: delegate to SimilarityService with proper parameters. when(config.isExcludeOwnerNamespaces()).thenReturn(false); when(config.getLevenshteinThreshold()).thenReturn(0.15); when(config.isCheckAgainstVerifiedOnly()).thenReturn(false); @@ -144,7 +136,6 @@ void shouldDelegateSimilarExtensionsToServiceWhenEnabled() { @Test void shouldSkipCheckForExistingExtensionWhenConfiguredForNewOnly() { // When configured for new extensions only, skip if extension already has versions. - when(config.isEnabled()).thenReturn(true); when(config.isNewExtensionsOnly()).thenReturn(true); when(repositories.countVersions("ns", "ext")).thenReturn(1); @@ -160,7 +151,6 @@ void shouldSkipCheckForExistingExtensionWhenConfiguredForNewOnly() { @Test void shouldCheckNewExtensionEvenWhenConfiguredForNewOnly() { // When configured for new extensions only, still check if extension has no versions. - when(config.isEnabled()).thenReturn(true); when(config.isNewExtensionsOnly()).thenReturn(true); when(config.getLevenshteinThreshold()).thenReturn(0.15); when(config.isCheckAgainstVerifiedOnly()).thenReturn(false); @@ -180,7 +170,6 @@ void shouldCheckNewExtensionEvenWhenConfiguredForNewOnly() { @Test void shouldSkipCheckForVerifiedPublisherWhenConfigured() { // When configured to skip verified publishers, check if namespace has owner memberships. - when(config.isEnabled()).thenReturn(true); when(config.isSkipVerifiedPublishers()).thenReturn(true); var namespace = new Namespace(); when(repositories.findNamespace("ns")).thenReturn(namespace); @@ -199,7 +188,6 @@ void shouldSkipCheckForVerifiedPublisherWhenConfigured() { @Test void shouldCheckVerifiedPublisherWhenSkipIsDisabled() { // When skip verified publishers is disabled, check even if namespace has owner memberships. - when(config.isEnabled()).thenReturn(true); when(config.isSkipVerifiedPublishers()).thenReturn(false); when(config.getLevenshteinThreshold()).thenReturn(0.15); when(config.isCheckAgainstVerifiedOnly()).thenReturn(false); @@ -217,7 +205,6 @@ void shouldCheckVerifiedPublisherWhenSkipIsDisabled() { @Test void shouldPassConfiguredThresholdAndVerifiedOnlyFlag() { // Verify that config values are correctly passed to SimilarityService. - when(config.isEnabled()).thenReturn(true); when(config.isExcludeOwnerNamespaces()).thenReturn(false); when(config.getLevenshteinThreshold()).thenReturn(0.25); when(config.isCheckAgainstVerifiedOnly()).thenReturn(true); @@ -233,21 +220,8 @@ void shouldPassConfiguredThresholdAndVerifiedOnlyFlag() { } @Test - void shouldReturnEmptyNamespacesWhenDisabled() { - // When disabled, namespace creation checks should not hit the database. - when(config.isEnabled()).thenReturn(false); - - var result = similarityCheckService.findSimilarNamespacesForCreation("ns", user); - - assertThat(result).isEmpty(); - verifyNoInteractions(similarityService); - verifyNoInteractions(repositories); - } - - @Test - void shouldDelegateSimilarNamespacesToServiceWhenEnabled() { - // When enabled, delegate to SimilarityService with config parameters. - when(config.isEnabled()).thenReturn(true); + void shouldDelegateSimilarNamespacesToService() { + // Delegate to SimilarityService with config parameters. when(config.isExcludeOwnerNamespaces()).thenReturn(false); when(config.getLevenshteinThreshold()).thenReturn(0.15); when(config.isCheckAgainstVerifiedOnly()).thenReturn(false); @@ -264,7 +238,6 @@ void shouldDelegateSimilarNamespacesToServiceWhenEnabled() { @Test void shouldExcludeOwnerNamespacesForNamespaceCreation() { // When exclude-owner-namespaces is enabled for namespace creation. - when(config.isEnabled()).thenReturn(true); when(config.isExcludeOwnerNamespaces()).thenReturn(true); when(config.getLevenshteinThreshold()).thenReturn(0.2); when(config.isCheckAgainstVerifiedOnly()).thenReturn(true); From fff73a60656a275ad02ecb6152774bfc1c968cd3 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Wed, 7 Jan 2026 20:33:46 +0100 Subject: [PATCH 5/9] remove fix for SecretScanningService after isEnabled has been externalized --- .../java/org/eclipse/openvsx/RegistryAPITest.java | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java index 3ee19afcb..8a04a6684 100644 --- a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java @@ -90,7 +90,8 @@ ClientRegistrationRepository.class, UpstreamRegistryService.class, GoogleCloudStorageService.class, AzureBlobStorageService.class, AwsStorageService.class, VSCodeIdService.class, DownloadCountService.class, CacheService.class, EclipseService.class, PublishExtensionVersionService.class, SimpleMeterRegistry.class, - JobRequestScheduler.class, ExtensionControlService.class, FileCacheDurationConfig.class, CdnServiceConfig.class + JobRequestScheduler.class, ExtensionControlService.class, FileCacheDurationConfig.class, CdnServiceConfig.class, + SecretScanningService.class }) class RegistryAPITest { @@ -109,20 +110,12 @@ class RegistryAPITest { @MockitoBean EntityManager entityManager; - @MockitoBean - SecretScanningService secretScanningService; - @Autowired MockMvc mockMvc; @Autowired ExtensionService extensions; - @BeforeEach - void setup() { - Mockito.when(secretScanningService.scanForSecrets(any())).thenReturn(SecretScanResult.skipped()); - } - @Test void testPublicNamespace() throws Exception { var namespace = mockNamespace(); From a28dd6c8c8072150d68828fba973bf7a39618e3c Mon Sep 17 00:00:00 2001 From: Alejandro Munoz Date: Mon, 12 Jan 2026 11:19:56 -0500 Subject: [PATCH 6/9] Add extension scanning worfklow and admin dashboard (#1537) * add configurable fast-fail pre-publish validation of extensions * add UI for admin Extension Scans dashboard --------- Co-authored-by: Alejandro Rivera --- server/src/dev/resources/application.yml | 7 +- .../eclipse/openvsx/ExtensionProcessor.java | 12 +- .../org/eclipse/openvsx/ExtensionService.java | 82 +- .../eclipse/openvsx/LocalRegistryService.java | 5 +- .../openvsx/admin/FileDecisionAPI.java | 448 ++++++++ .../org/eclipse/openvsx/admin/ScanAPI.java | 962 ++++++++++++++++++ .../openvsx/entities/AdminScanDecision.java | 169 +++ .../openvsx/entities/ExtensionScan.java | 263 +++++ .../openvsx/entities/ExtensionThreat.java | 213 ++++ .../entities/ExtensionValidationFailure.java | 163 +++ .../openvsx/entities/FileDecision.java | 247 +++++ .../eclipse/openvsx/entities/ScanStatus.java | 48 + .../openvsx/json/AdminDecisionJson.java | 59 ++ .../openvsx/json/FileDecisionCountsJson.java | 59 ++ .../openvsx/json/FileDecisionJson.java | 169 +++ .../openvsx/json/FileDecisionListJson.java | 74 ++ .../openvsx/json/FileDecisionRequest.java | 46 + .../json/FileDecisionResponseJson.java | 85 ++ .../openvsx/json/FileDecisionResultJson.java | 103 ++ .../openvsx/json/ScanDecisionRequest.java | 50 + .../json/ScanDecisionResponseJson.java | 71 ++ .../openvsx/json/ScanDecisionResultJson.java | 73 ++ .../openvsx/json/ScanFilterOptionsJson.java | 61 ++ .../eclipse/openvsx/json/ScanResultJson.java | 248 +++++ .../openvsx/json/ScanResultListJson.java | 78 ++ .../openvsx/json/ScanStatisticsJson.java | 148 +++ .../org/eclipse/openvsx/json/ThreatJson.java | 150 +++ .../openvsx/json/ValidationFailureJson.java | 102 ++ .../PublishExtensionVersionHandler.java | 62 +- .../AdminScanDecisionRepository.java | 124 +++ .../repositories/ExtensionScanRepository.java | 317 ++++++ .../ExtensionThreatRepository.java | 83 ++ .../ExtensionValidationFailureRepository.java | 80 ++ .../repositories/FileDecisionRepository.java | 118 +++ .../repositories/RepositoryService.java | 461 ++++++++- .../openvsx/scanning/ExtensionScanConfig.java | 45 + .../ExtensionScanPersistenceService.java | 194 ++++ .../scanning/ExtensionScanService.java | 258 +++++ .../openvsx/scanning/ExtensionScanner.java | 180 ++++ .../scanning/GitleaksRulesGenerator.java | 18 +- .../openvsx/scanning/SecretFinding.java | 4 +- .../openvsx/scanning/SecretRuleLoader.java | 6 +- .../openvsx/scanning/SecretScanner.java | 2 +- .../scanning/SecretScannerFactory.java | 20 +- .../scanning/SecretScanningConfig.java | 34 +- .../scanning/SecretScanningService.java | 67 +- .../openvsx/scanning/StaleScanRecovery.java | 75 ++ .../openvsx/scanning/ValidationCheck.java | 104 ++ .../search/SimilarityCheckService.java | 81 +- .../openvsx/search/SimilarityConfig.java | 18 + .../V1_59__Extension_Scan_Tables.sql | 219 ++++ .../secret-scanning-custom-rules.yaml | 1 + .../org/eclipse/openvsx/RegistryAPITest.java | 26 +- .../java/org/eclipse/openvsx/UserAPITest.java | 16 +- .../eclipse/openvsx/admin/AdminAPITest.java | 16 +- .../eclipse/openvsx/admin/ScanAPITest.java | 406 ++++++++ .../openvsx/eclipse/EclipseServiceTest.java | 16 +- .../PublishExtensionVersionHandlerTest.java | 144 +-- .../RepositoryServiceSmokeTest.java | 124 ++- .../ExtensionScanServiceEnforcementTest.java | 279 +++++ .../scanning/SecretScannerFactoryTest.java | 13 +- .../scanning/SecretScanningServiceTest.java | 3 +- .../search/SimilarityCheckServiceTest.java | 59 +- .../scan-admin/common/auto-refresh.tsx | 79 ++ .../scan-admin/common/conditional-tooltip.tsx | 74 ++ .../scan-admin/common/file-table.tsx | 508 +++++++++ .../src/components/scan-admin/common/index.ts | 18 + .../scan-admin/common/tab-panel.tsx | 42 + .../src/components/scan-admin/common/utils.ts | 48 + .../scan-admin/dialogs/file-dialog.tsx | 137 +++ .../components/scan-admin/dialogs/index.ts | 15 + .../scan-admin/dialogs/quarantine-dialog.tsx | 236 +++++ webui/src/components/scan-admin/index.ts | 52 + .../components/scan-admin/scan-card/index.ts | 20 + .../scan-card/scan-card-content.tsx | 457 +++++++++ .../scan-card-expand-strip-badges.tsx | 180 ++++ .../scan-card/scan-card-expand-strip.tsx | 104 ++ .../scan-card/scan-card-expanded-content.tsx | 153 +++ .../scan-admin/scan-card/scan-card-header.tsx | 172 ++++ .../scan-admin/scan-card/scan-card.tsx | 152 +++ .../scan-admin/scan-card/scan-detail-card.tsx | 144 +++ .../components/scan-admin/scan-card/utils.ts | 186 ++++ .../tab-contents/allow-list-tab-content.tsx | 119 +++ .../auto-rejected-tab-content.tsx | 106 ++ .../tab-contents/block-list-tab-content.tsx | 119 +++ .../scan-admin/tab-contents/index.ts | 18 + .../tab-contents/quarantined-tab-content.tsx | 158 +++ .../tab-contents/scans-tab-content.tsx | 113 ++ .../scan-admin/toolbars/counts-toolbar.tsx | 262 +++++ .../components/scan-admin/toolbars/index.ts | 16 + .../scan-admin/toolbars/search-toolbar.tsx | 255 +++++ .../scan-admin/toolbars/tab-toolbar.tsx | 56 + webui/src/context/scan-admin/index.ts | 43 + webui/src/context/scan-admin/scan-actions.ts | 87 ++ .../context/scan-admin/scan-api-actions.ts | 137 +++ .../context/scan-admin/scan-api-effects.ts | 479 +++++++++ .../context/scan-admin/scan-context-types.ts | 102 ++ webui/src/context/scan-admin/scan-context.tsx | 123 +++ webui/src/context/scan-admin/scan-helpers.ts | 63 ++ webui/src/context/scan-admin/scan-reducer.ts | 321 ++++++ webui/src/context/scan-admin/scan-types.ts | 321 ++++++ webui/src/default/theme.tsx | 111 +- webui/src/extension-registry-service.ts | 177 +++- webui/src/extension-registry-types.ts | 155 +++ webui/src/hooks/scan-admin/index.ts | 48 + .../hooks/scan-admin/use-auto-rejected-tab.ts | 85 ++ webui/src/hooks/scan-admin/use-dialogs.ts | 83 ++ .../src/hooks/scan-admin/use-file-list-tab.ts | 149 +++ webui/src/hooks/scan-admin/use-pagination.ts | 90 ++ .../hooks/scan-admin/use-quarantined-tab.ts | 138 +++ .../hooks/scan-admin/use-scan-card-state.ts | 73 ++ .../src/hooks/scan-admin/use-scan-filters.ts | 83 ++ webui/src/hooks/scan-admin/use-scans-tab.ts | 92 ++ webui/src/hooks/scan-admin/use-search.ts | 54 + .../hooks/scan-admin/use-tab-navigation.ts | 82 ++ webui/src/hooks/scan-admin/use-url-sync.ts | 293 ++++++ .../pages/admin-dashboard/admin-dashboard.tsx | 28 +- .../src/pages/admin-dashboard/scan-admin.tsx | 109 ++ webui/src/pages/admin-dashboard/welcome.tsx | 1 + 119 files changed, 15040 insertions(+), 354 deletions(-) create mode 100644 server/src/main/java/org/eclipse/openvsx/admin/FileDecisionAPI.java create mode 100644 server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java create mode 100644 server/src/main/java/org/eclipse/openvsx/entities/AdminScanDecision.java create mode 100644 server/src/main/java/org/eclipse/openvsx/entities/ExtensionScan.java create mode 100644 server/src/main/java/org/eclipse/openvsx/entities/ExtensionThreat.java create mode 100644 server/src/main/java/org/eclipse/openvsx/entities/ExtensionValidationFailure.java create mode 100644 server/src/main/java/org/eclipse/openvsx/entities/FileDecision.java create mode 100644 server/src/main/java/org/eclipse/openvsx/entities/ScanStatus.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/AdminDecisionJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/FileDecisionCountsJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/FileDecisionJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/FileDecisionListJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/FileDecisionRequest.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/FileDecisionResponseJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/FileDecisionResultJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/ScanDecisionRequest.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/ScanDecisionResponseJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/ScanDecisionResultJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/ScanFilterOptionsJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/ScanResultJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/ScanResultListJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/ScanStatisticsJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/ThreatJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/ValidationFailureJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/repositories/AdminScanDecisionRepository.java create mode 100644 server/src/main/java/org/eclipse/openvsx/repositories/ExtensionScanRepository.java create mode 100644 server/src/main/java/org/eclipse/openvsx/repositories/ExtensionThreatRepository.java create mode 100644 server/src/main/java/org/eclipse/openvsx/repositories/ExtensionValidationFailureRepository.java create mode 100644 server/src/main/java/org/eclipse/openvsx/repositories/FileDecisionRepository.java create mode 100644 server/src/main/java/org/eclipse/openvsx/scanning/ExtensionScanConfig.java create mode 100644 server/src/main/java/org/eclipse/openvsx/scanning/ExtensionScanPersistenceService.java create mode 100644 server/src/main/java/org/eclipse/openvsx/scanning/ExtensionScanService.java create mode 100644 server/src/main/java/org/eclipse/openvsx/scanning/ExtensionScanner.java create mode 100644 server/src/main/java/org/eclipse/openvsx/scanning/StaleScanRecovery.java create mode 100644 server/src/main/java/org/eclipse/openvsx/scanning/ValidationCheck.java create mode 100644 server/src/main/resources/db/migration/V1_59__Extension_Scan_Tables.sql create mode 100644 server/src/test/java/org/eclipse/openvsx/admin/ScanAPITest.java create mode 100644 server/src/test/java/org/eclipse/openvsx/scanning/ExtensionScanServiceEnforcementTest.java create mode 100644 webui/src/components/scan-admin/common/auto-refresh.tsx create mode 100644 webui/src/components/scan-admin/common/conditional-tooltip.tsx create mode 100644 webui/src/components/scan-admin/common/file-table.tsx create mode 100644 webui/src/components/scan-admin/common/index.ts create mode 100644 webui/src/components/scan-admin/common/tab-panel.tsx create mode 100644 webui/src/components/scan-admin/common/utils.ts create mode 100644 webui/src/components/scan-admin/dialogs/file-dialog.tsx create mode 100644 webui/src/components/scan-admin/dialogs/index.ts create mode 100644 webui/src/components/scan-admin/dialogs/quarantine-dialog.tsx create mode 100644 webui/src/components/scan-admin/index.ts create mode 100644 webui/src/components/scan-admin/scan-card/index.ts create mode 100644 webui/src/components/scan-admin/scan-card/scan-card-content.tsx create mode 100644 webui/src/components/scan-admin/scan-card/scan-card-expand-strip-badges.tsx create mode 100644 webui/src/components/scan-admin/scan-card/scan-card-expand-strip.tsx create mode 100644 webui/src/components/scan-admin/scan-card/scan-card-expanded-content.tsx create mode 100644 webui/src/components/scan-admin/scan-card/scan-card-header.tsx create mode 100644 webui/src/components/scan-admin/scan-card/scan-card.tsx create mode 100644 webui/src/components/scan-admin/scan-card/scan-detail-card.tsx create mode 100644 webui/src/components/scan-admin/scan-card/utils.ts create mode 100644 webui/src/components/scan-admin/tab-contents/allow-list-tab-content.tsx create mode 100644 webui/src/components/scan-admin/tab-contents/auto-rejected-tab-content.tsx create mode 100644 webui/src/components/scan-admin/tab-contents/block-list-tab-content.tsx create mode 100644 webui/src/components/scan-admin/tab-contents/index.ts create mode 100644 webui/src/components/scan-admin/tab-contents/quarantined-tab-content.tsx create mode 100644 webui/src/components/scan-admin/tab-contents/scans-tab-content.tsx create mode 100644 webui/src/components/scan-admin/toolbars/counts-toolbar.tsx create mode 100644 webui/src/components/scan-admin/toolbars/index.ts create mode 100644 webui/src/components/scan-admin/toolbars/search-toolbar.tsx create mode 100644 webui/src/components/scan-admin/toolbars/tab-toolbar.tsx create mode 100644 webui/src/context/scan-admin/index.ts create mode 100644 webui/src/context/scan-admin/scan-actions.ts create mode 100644 webui/src/context/scan-admin/scan-api-actions.ts create mode 100644 webui/src/context/scan-admin/scan-api-effects.ts create mode 100644 webui/src/context/scan-admin/scan-context-types.ts create mode 100644 webui/src/context/scan-admin/scan-context.tsx create mode 100644 webui/src/context/scan-admin/scan-helpers.ts create mode 100644 webui/src/context/scan-admin/scan-reducer.ts create mode 100644 webui/src/context/scan-admin/scan-types.ts create mode 100644 webui/src/hooks/scan-admin/index.ts create mode 100644 webui/src/hooks/scan-admin/use-auto-rejected-tab.ts create mode 100644 webui/src/hooks/scan-admin/use-dialogs.ts create mode 100644 webui/src/hooks/scan-admin/use-file-list-tab.ts create mode 100644 webui/src/hooks/scan-admin/use-pagination.ts create mode 100644 webui/src/hooks/scan-admin/use-quarantined-tab.ts create mode 100644 webui/src/hooks/scan-admin/use-scan-card-state.ts create mode 100644 webui/src/hooks/scan-admin/use-scan-filters.ts create mode 100644 webui/src/hooks/scan-admin/use-scans-tab.ts create mode 100644 webui/src/hooks/scan-admin/use-search.ts create mode 100644 webui/src/hooks/scan-admin/use-tab-navigation.ts create mode 100644 webui/src/hooks/scan-admin/use-url-sync.ts create mode 100644 webui/src/pages/admin-dashboard/scan-admin.tsx diff --git a/server/src/dev/resources/application.yml b/server/src/dev/resources/application.yml index 3d483a2e1..83862d9bd 100644 --- a/server/src/dev/resources/application.yml +++ b/server/src/dev/resources/application.yml @@ -170,8 +170,11 @@ ovsx: revoked-access-tokens: subject: 'Open VSX Access Tokens Revoked' template: 'revoked-access-tokens.html' + scanning: + enabled: true similarity: enabled: true + enforced: true levenshtein-threshold: 0.2 skip-verified-publishers: true check-against-verified-only: true @@ -185,8 +188,8 @@ ovsx: force-regenerate-rules: false generated-rules-path: '/tmp/secret-scanning-rules-gitleaks.yaml' max-file-size-bytes: 5242880 - max-entry-count: 5000 - max-total-uncompressed-bytes: 104857600 + max-entry-count: 50000 + max-total-uncompressed-bytes: 524288000 max-findings: 200 max-line-length: 10000 long-line-no-space-threshold: 1000 diff --git a/server/src/main/java/org/eclipse/openvsx/ExtensionProcessor.java b/server/src/main/java/org/eclipse/openvsx/ExtensionProcessor.java index bc3baeb43..9ff5918d6 100644 --- a/server/src/main/java/org/eclipse/openvsx/ExtensionProcessor.java +++ b/server/src/main/java/org/eclipse/openvsx/ExtensionProcessor.java @@ -262,7 +262,8 @@ public String getVersion() { return vsixManifest.path(MANIFEST_METADATA).path(MANIFEST_IDENTITY).path("Version").asText(); } - private String getTargetPlatform() { + public String getTargetPlatform() { + loadVsixManifest(); var targetPlatform = vsixManifest.path(MANIFEST_METADATA).path(MANIFEST_IDENTITY).path("TargetPlatform").asText(); if (targetPlatform.isEmpty()) { targetPlatform = TargetPlatform.NAME_UNIVERSAL; @@ -271,6 +272,15 @@ private String getTargetPlatform() { return targetPlatform; } + public String getDisplayName() { + loadVsixManifest(); + var displayName = vsixManifest.path(MANIFEST_METADATA).path("DisplayName").asText(); + if (StringUtils.isBlank(displayName)) { + return getExtensionName(); + } + return displayName; + } + private List getTags() { var tags = vsixManifest.path(MANIFEST_METADATA).path("Tags").asText(); return asStringList(tags, ",").stream() diff --git a/server/src/main/java/org/eclipse/openvsx/ExtensionService.java b/server/src/main/java/org/eclipse/openvsx/ExtensionService.java index 67477374e..7e3e1c0f5 100644 --- a/server/src/main/java/org/eclipse/openvsx/ExtensionService.java +++ b/server/src/main/java/org/eclipse/openvsx/ExtensionService.java @@ -22,7 +22,7 @@ import org.eclipse.openvsx.json.TargetPlatformVersionJson; import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; import org.eclipse.openvsx.repositories.RepositoryService; -import org.eclipse.openvsx.scanning.SecretScanningService; +import org.eclipse.openvsx.scanning.ExtensionScanService; import org.eclipse.openvsx.search.SearchUtilService; import org.eclipse.openvsx.util.ErrorResultException; import org.eclipse.openvsx.util.NamingUtil; @@ -47,7 +47,6 @@ @Component public class ExtensionService { - private static final int MAX_CONTENT_SIZE = 512 * 1024 * 1024; private final EntityManager entityManager; @@ -56,7 +55,7 @@ public class ExtensionService { private final CacheService cache; private final PublishExtensionVersionHandler publishHandler; private final JobRequestScheduler scheduler; - private final SecretScanningService secretScanningService; + private final ExtensionScanService scanService; @Value("${ovsx.publishing.require-license:false}") boolean requireLicense; @@ -71,7 +70,7 @@ public ExtensionService( CacheService cache, PublishExtensionVersionHandler publishHandler, JobRequestScheduler scheduler, - SecretScanningService secretScanningService + ExtensionScanService scanService ) { this.entityManager = entityManager; this.repositories = repositories; @@ -79,7 +78,7 @@ public ExtensionService( this.cache = cache; this.publishHandler = publishHandler; this.scheduler = scheduler; - this.secretScanningService = secretScanningService; + this.scanService = scanService; } @Transactional @@ -90,43 +89,50 @@ public ExtensionVersion mirrorVersion(TempFile extensionFile, String signatureNa } public ExtensionVersion publishVersion(InputStream content, PersonalAccessToken token) throws ErrorResultException { - var extensionFile = createExtensionFile(content); - doPublish(extensionFile, null, token, TimeUtil.getCurrentUTC(), true); - publishHandler.publishAsync(extensionFile, this); - var download = extensionFile.getResource(); - publishHandler.schedulePublicIdJob(download); - return download.getExtension(); + if (scanService.isEnabled()) { + return publishVersionWithScan(content, token); + } else { + var extensionFile = createExtensionFile(content); + doPublish(extensionFile, null, token, TimeUtil.getCurrentUTC(), true); + publishHandler.publishAsync(extensionFile, this); + var download = extensionFile.getResource(); + publishHandler.schedulePublicIdJob(download); + return download.getExtension(); + } } - private void doPublish(TempFile extensionFile, String binaryName, PersonalAccessToken token, LocalDateTime timestamp, boolean checkDependencies) { - // Scan for secrets before processing the extension - // This fails fast if secrets are detected, preventing publication - if (secretScanningService.isEnabled()) { - var scanResult = secretScanningService.scanForSecrets(extensionFile); - if (scanResult.isSecretsFound()) { - var findings = scanResult.getFindings(); - var errorMessage = new StringBuilder(); - errorMessage.append("Extension publication blocked: potential secrets detected in the package.\n\n"); - errorMessage.append("The following potential secrets were found:\n"); - - int maxFindings = Math.min(5, findings.size()); - for (int i = 0; i < maxFindings; i++) { - errorMessage.append(" ").append(i + 1).append(". ").append(findings.get(i).toString()).append("\n"); - } - - if (findings.size() > maxFindings) { - errorMessage.append(" ... and ").append(findings.size() - maxFindings).append(" more\n"); - } - - errorMessage.append("\nPlease remove these secrets before publishing. "); - errorMessage.append("Consider using environment variables or configuration files that are not included in the package. "); - - errorMessage.append("Refer to the publishing guidelines: https://github.com/EclipseFdn/open-vsx.org/wiki/Publishing-Extensions"); - - throw new ErrorResultException(errorMessage.toString()); + private ExtensionVersion publishVersionWithScan(InputStream content, PersonalAccessToken token) throws ErrorResultException { + var extensionFile = createExtensionFile(content); + ExtensionScan scan = null; + + try (var processor = new ExtensionProcessor(extensionFile)) { + scan = scanService.initializeScan(processor, token.getUser()); + + scanService.runValidation(scan, extensionFile, token.getUser()); + + doPublish(extensionFile, null, token, TimeUtil.getCurrentUTC(), true); + + scanService.runScan(scan, extensionFile, token.getUser()); + + publishHandler.publishAsync(extensionFile, this, scan); + var download = extensionFile.getResource(); + publishHandler.schedulePublicIdJob(extensionFile.getResource()); + return download.getExtension(); + } catch (ErrorResultException e) { + // ErrorResultException is thrown by doPublish when the extension is not valid, so we can remove the scan + if (scan != null && !scan.isCompleted()) { + scanService.removeScan(scan); + } + throw e; + } catch (Exception e) { + if (scan != null && !scan.isCompleted()) { + scanService.markScanAsErrored(scan, "Unexpected error: " + e.getMessage()); } + throw e; } - + } + + private void doPublish(TempFile extensionFile, String binaryName, PersonalAccessToken token, LocalDateTime timestamp, boolean checkDependencies) { try (var processor = new ExtensionProcessor(extensionFile)) { var extVersion = publishHandler.createExtensionVersion(processor, token, timestamp, checkDependencies); if (requireLicense) { diff --git a/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java b/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java index 04c0893bc..d2ffbce52 100644 --- a/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java +++ b/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java @@ -25,6 +25,7 @@ import org.eclipse.openvsx.search.SearchUtilService; import org.eclipse.openvsx.search.SimilarityCheckService; import org.eclipse.openvsx.storage.StorageUtilService; +import javax.annotation.Nullable; import org.eclipse.openvsx.util.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -78,7 +79,7 @@ public LocalRegistryService( EclipseService eclipse, CacheService cache, ExtensionVersionIntegrityService integrityService, - SimilarityCheckService similarityCheckService + @Nullable SimilarityCheckService similarityCheckService ) { this.entityManager = entityManager; this.repositories = repositories; @@ -600,7 +601,7 @@ public ResultJson createNamespace(NamespaceJson json, UserData user) { } // Check if the proposed namespace name is too similar to existing ones (if enabled) - if (similarityCheckService.isEnabled()) { + if (similarityCheckService != null && similarityCheckService.isEnabled()) { var similarNamespaces = similarityCheckService.findSimilarNamespacesForCreation(json.getName(), user); if (!similarNamespaces.isEmpty()) { var similarNames = similarNamespaces.stream() diff --git a/server/src/main/java/org/eclipse/openvsx/admin/FileDecisionAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/FileDecisionAPI.java new file mode 100644 index 000000000..e5bc29294 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/admin/FileDecisionAPI.java @@ -0,0 +1,448 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.admin; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import org.eclipse.openvsx.entities.FileDecision; +import org.eclipse.openvsx.json.*; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.TimeUtil; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.Locale; +import java.util.stream.Collectors; + +/** + * REST API for file decision management (allow/block lists). + * Provides endpoints for managing file-level security decisions. + */ +@RestController +@RequestMapping("/admin/api") +@ApiResponse( + responseCode = "403", + description = "Administration role is required", + content = @Content() +) +public class FileDecisionAPI { + + private final RepositoryService repositories; + private final AdminService admins; + + public FileDecisionAPI(RepositoryService repositories, AdminService admins) { + this.repositories = repositories; + this.admins = admins; + } + + @GetMapping( + path = "/files", + produces = MediaType.APPLICATION_JSON_VALUE + ) + @CrossOrigin + @Operation(summary = "Get files with admin decisions") + @ApiResponse( + responseCode = "200", + description = "List of file decisions", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = FileDecisionListJson.class) + ) + ) + public ResponseEntity getFiles( + @RequestParam(required = false) + @Parameter(description = "Filter by admin decision type", schema = @Schema(type = "string", allowableValues = {"allowed", "blocked"})) + String decision, + @RequestParam(required = false) + @Parameter(description = "Filter by publisher name") + String publisher, + @RequestParam(required = false) + @Parameter(description = "Filter by namespace") + String namespace, + @RequestParam(required = false) + @Parameter(description = "Filter by display name, extension name, or file name") + String name, + @RequestParam(defaultValue = "18") + @Parameter(description = "Maximum number of entries to return", schema = @Schema(type = "integer", minimum = "0", defaultValue = "18")) + int size, + @RequestParam(defaultValue = "0") + @Parameter(description = "Number of entries to skip", schema = @Schema(type = "integer", minimum = "0", defaultValue = "0")) + int offset, + @RequestParam(defaultValue = "dateDecided") + @Parameter(description = "Field to sort by", schema = @Schema(type = "string", allowableValues = {"dateDecided", "fileName", "publisher", "namespace"}, defaultValue = "dateDecided")) + String sortBy, + @RequestParam(defaultValue = "desc") + @Parameter(description = "Sort order", schema = @Schema(type = "string", allowableValues = {"asc", "desc"}, defaultValue = "desc")) + String sortOrder, + @RequestParam(required = false) + @Parameter(description = "Filter files decided on or after this date (ISO 8601 format)") + String dateDecidedFrom, + @RequestParam(required = false) + @Parameter(description = "Filter files decided on or before this date (ISO 8601 format)") + String dateDecidedTo + ) { + try { + admins.checkAdminUser(); + + if (size < 0) { + throw new ErrorResultException("Parameter 'size' must be >= 0", HttpStatus.BAD_REQUEST); + } + if (offset < 0) { + throw new ErrorResultException("Parameter 'offset' must be >= 0", HttpStatus.BAD_REQUEST); + } + + var decidedFrom = parseUtcDateTime(dateDecidedFrom, "dateDecidedFrom"); + var decidedTo = parseUtcDateTime(dateDecidedTo, "dateDecidedTo"); + var dbSortField = toFileSortField(sortBy); + var ascending = normalizeSortOrder(sortOrder); + + var sort = Sort.by(ascending ? Sort.Direction.ASC : Sort.Direction.DESC, dbSortField); + var pageNumber = offset / Math.max(size, 1); + var pageable = PageRequest.of(pageNumber, size, sort); + + var page = repositories.findFileDecisionsFiltered( + decision, publisher, namespace, name, decidedFrom, decidedTo, pageable + ); + + var result = new FileDecisionListJson(); + result.setTotalSize((int) page.getTotalElements()); + result.setOffset(offset); + result.setFiles(page.getContent().stream() + .map(this::toFileDecisionJson) + .collect(Collectors.toList())); + + return ResponseEntity.ok(result); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(FileDecisionListJson.class); + } + } + + @GetMapping( + path = "/files/{fileId}", + produces = MediaType.APPLICATION_JSON_VALUE + ) + @CrossOrigin + @Operation(summary = "Get specific file decision") + @ApiResponse( + responseCode = "200", + description = "File decision details", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = FileDecisionJson.class) + ) + ) + @ApiResponse( + responseCode = "404", + description = "File decision not found", + content = @Content() + ) + public ResponseEntity getFileDecision( + @PathVariable @Parameter(description = "File decision ID", example = "123") long fileId + ) { + try { + admins.checkAdminUser(); + + var decision = repositories.findFileDecision(fileId); + if (decision == null) { + throw new ErrorResultException("File decision not found: " + fileId, HttpStatus.NOT_FOUND); + } + + var result = toFileDecisionJson(decision); + return ResponseEntity.ok(result); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(FileDecisionJson.class); + } + } + + @PostMapping( + path = "/files/decisions", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + @CrossOrigin + @Operation(summary = "Create or update file decisions") + @ApiResponse( + responseCode = "200", + description = "Decisions processed successfully", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = FileDecisionResponseJson.Create.class) + ) + ) + @ApiResponse( + responseCode = "400", + description = "Invalid request", + content = @Content() + ) + public ResponseEntity makeFileDecisions( + @RequestBody FileDecisionRequest.Create request + ) { + try { + var adminUser = admins.checkAdminUser(); + + if (request.fileHashes() == null || request.fileHashes().isEmpty()) { + throw new ErrorResultException("File hashes are required", HttpStatus.BAD_REQUEST); + } + if (request.decision() == null || request.decision().isBlank()) { + throw new ErrorResultException("Decision is required", HttpStatus.BAD_REQUEST); + } + + var decisionValue = parseFileDecision(request.decision()); + + var results = new java.util.ArrayList(); + int successful = 0; + int failed = 0; + + for (var fileHash : request.fileHashes()) { + try { + if (fileHash == null || fileHash.isBlank()) { + results.add(FileDecisionResultJson.Create.failure(fileHash, "Empty file hash")); + failed++; + continue; + } + + var existingDecision = repositories.findFileDecisionByHash(fileHash); + if (existingDecision != null) { + existingDecision.setDecision(decisionValue); + existingDecision.setDecidedBy(adminUser); + existingDecision.setDecidedAt(LocalDateTime.now()); + repositories.saveFileDecision(existingDecision); + } else { + FileDecision newDecision; + if (FileDecision.ALLOWED.equals(decisionValue)) { + newDecision = FileDecision.allowed(fileHash, adminUser); + } else { + newDecision = FileDecision.blocked(fileHash, adminUser); + } + repositories.saveFileDecision(newDecision); + } + + results.add(FileDecisionResultJson.Create.success(fileHash)); + successful++; + } catch (Exception e) { + results.add(FileDecisionResultJson.Create.failure(fileHash, e.getMessage())); + failed++; + } + } + + var response = new FileDecisionResponseJson.Create(); + response.setProcessed(request.fileHashes().size()); + response.setSuccessful(successful); + response.setFailed(failed); + response.setResults(results); + + return ResponseEntity.ok(response); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(FileDecisionResponseJson.Create.class); + } + } + + @DeleteMapping( + path = "/files/decisions", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + @CrossOrigin + @Operation(summary = "Remove file decisions") + @ApiResponse( + responseCode = "200", + description = "Deletions processed successfully", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = FileDecisionResponseJson.Delete.class) + ) + ) + @ApiResponse( + responseCode = "400", + description = "Invalid request", + content = @Content() + ) + public ResponseEntity deleteFileDecisions( + @RequestBody FileDecisionRequest.Delete request + ) { + try { + admins.checkAdminUser(); + + if (request.fileIds() == null || request.fileIds().isEmpty()) { + throw new ErrorResultException("File IDs are required", HttpStatus.BAD_REQUEST); + } + + var results = new java.util.ArrayList(); + int successful = 0; + int failed = 0; + + for (var fileId : request.fileIds()) { + try { + if (fileId == null) { + results.add(FileDecisionResultJson.Delete.failure(null, "File ID is null")); + failed++; + continue; + } + + var decision = repositories.findFileDecision(fileId); + if (decision == null) { + results.add(FileDecisionResultJson.Delete.failure(fileId, "File decision not found")); + failed++; + continue; + } + + repositories.deleteFileDecision(fileId); + results.add(FileDecisionResultJson.Delete.success(fileId)); + successful++; + } catch (Exception e) { + results.add(FileDecisionResultJson.Delete.failure(fileId, e.getMessage())); + failed++; + } + } + + var response = new FileDecisionResponseJson.Delete(); + response.setProcessed(request.fileIds().size()); + response.setSuccessful(successful); + response.setFailed(failed); + response.setResults(results); + + return ResponseEntity.ok(response); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(FileDecisionResponseJson.Delete.class); + } + } + + @GetMapping( + path = "/files/counts", + produces = MediaType.APPLICATION_JSON_VALUE + ) + @CrossOrigin + @Operation(summary = "Get file decision counts") + @ApiResponse( + responseCode = "200", + description = "File decision counts", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = FileDecisionCountsJson.class) + ) + ) + public ResponseEntity getFileDecisionCounts( + @RequestParam(required = false) + @Parameter(description = "Filter files decided on or after this date (ISO 8601 format)") + String dateDecidedFrom, + @RequestParam(required = false) + @Parameter(description = "Filter files decided on or before this date (ISO 8601 format)") + String dateDecidedTo + ) { + try { + admins.checkAdminUser(); + + var decidedFrom = parseUtcDateTime(dateDecidedFrom, "dateDecidedFrom"); + var decidedTo = parseUtcDateTime(dateDecidedTo, "dateDecidedTo"); + + var counts = new FileDecisionCountsJson(); + + if (decidedFrom == null && decidedTo == null) { + counts.setAllowed((int) repositories.countFileDecisions(FileDecision.ALLOWED)); + counts.setBlocked((int) repositories.countFileDecisions(FileDecision.BLOCKED)); + } else { + counts.setAllowed((int) repositories.countFileDecisionsByDateRange(FileDecision.ALLOWED, decidedFrom, decidedTo)); + counts.setBlocked((int) repositories.countFileDecisionsByDateRange(FileDecision.BLOCKED, decidedFrom, decidedTo)); + } + counts.setTotal(counts.getAllowed() + counts.getBlocked()); + + return ResponseEntity.ok(counts); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(FileDecisionCountsJson.class); + } + } + + private String parseFileDecision(String decision) { + var normalized = decision.trim().toLowerCase(Locale.ROOT); + return switch (normalized) { + case "allowed" -> FileDecision.ALLOWED; + case "blocked" -> FileDecision.BLOCKED; + default -> throw new ErrorResultException( + "Invalid decision value: " + decision + ". Must be 'allowed' or 'blocked'", + HttpStatus.BAD_REQUEST + ); + }; + } + + private String toFileSortField(String sortBy) { + if (sortBy == null) { + return "decided_at"; + } + return switch (sortBy.toLowerCase(Locale.ROOT)) { + case "datedecided" -> "decided_at"; + case "filename" -> "file_name"; + case "publisher" -> "publisher"; + case "namespace" -> "namespace_name"; + default -> throw new ErrorResultException("Unsupported sortBy value: " + sortBy, HttpStatus.BAD_REQUEST); + }; + } + + private boolean normalizeSortOrder(String sortOrder) { + if (sortOrder == null) { + return false; + } + return switch (sortOrder.toLowerCase(Locale.ROOT)) { + case "asc" -> true; + case "desc" -> false; + default -> throw new ErrorResultException("Unsupported sortOrder value: " + sortOrder, HttpStatus.BAD_REQUEST); + }; + } + + private LocalDateTime parseUtcDateTime(String raw, String paramName) { + if (raw == null || raw.isBlank()) { + return null; + } + try { + return TimeUtil.fromUTCString(raw); + } catch (Exception e) { + throw new ErrorResultException( + "Invalid ISO date-time for parameter '" + paramName + "': " + raw, + HttpStatus.BAD_REQUEST + ); + } + } + + private FileDecisionJson toFileDecisionJson(FileDecision decision) { + var json = new FileDecisionJson(); + json.setId(String.valueOf(decision.getId())); + json.setFileHash(decision.getFileHash()); + json.setFileName(decision.getFileName()); + json.setFileType(decision.getFileType()); + + json.setDecision(FileDecision.ALLOWED.equals(decision.getDecision()) ? "allowed" : "blocked"); + json.setDecidedBy(decision.getDecidedByName()); + json.setDateDecided(TimeUtil.toUTCString(decision.getDecidedAt())); + + json.setDisplayName(decision.getDisplayName()); + json.setNamespace(decision.getNamespaceName()); + json.setExtensionName(decision.getExtensionName()); + json.setPublisher(decision.getPublisher()); + json.setVersion(decision.getVersion()); + + if (decision.getScan() != null) { + json.setScanId(String.valueOf(decision.getScan().getId())); + } + + return json; + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java new file mode 100644 index 000000000..b947e3bdc --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java @@ -0,0 +1,962 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.admin; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.Explode; +import io.swagger.v3.oas.annotations.enums.ParameterStyle; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import org.eclipse.openvsx.entities.AdminScanDecision; +import org.eclipse.openvsx.entities.ExtensionScan; +import org.eclipse.openvsx.entities.ExtensionThreat; +import org.eclipse.openvsx.entities.ExtensionVersion; +import org.eclipse.openvsx.entities.FileDecision; +import org.eclipse.openvsx.entities.ScanStatus; +import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.json.*; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.storage.StorageUtilService; +import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.TimeUtil; +import org.eclipse.openvsx.util.UrlUtil; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; + +/** + * REST API for extension scan management. + * Provides endpoints for listing and retrieving scan results. + * Used by the admin dashboard to monitor extension validation and scanning. + */ +@RestController +@RequestMapping("/admin/api") +@ApiResponse( + responseCode = "403", + description = "Administration role is required", + content = @Content() +) +public class ScanAPI { + + private final RepositoryService repositories; + private final AdminService admins; + private final StorageUtilService storageUtil; + + public ScanAPI( + RepositoryService repositories, + AdminService admins, + StorageUtilService storageUtil + ) { + this.repositories = repositories; + this.admins = admins; + this.storageUtil = storageUtil; + } + + /** + * Get aggregated scan counts by status and admin decisions counts. + */ + @GetMapping( + path = "/scans/counts", + produces = MediaType.APPLICATION_JSON_VALUE + ) + @CrossOrigin + @Operation(summary = "Get scan counts") + @ApiResponse( + responseCode = "200", + description = "Scan counts by status and quarantine decision", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ScanStatisticsJson.class) + ) + ) + public ResponseEntity getScanCounts( + @RequestParam(required = false) + @Parameter(description = "Filter scans started on or after this date (ISO 8601 format)") + String dateStartedFrom, + @RequestParam(required = false) + @Parameter(description = "Filter scans started on or before this date (ISO 8601 format)") + String dateStartedTo, + @RequestParam(defaultValue = "all") + @Parameter(description = "Filter by enforcement status of threats/validations", schema = @Schema(type = "string", allowableValues = {"enforced", "notEnforced", "all"}, defaultValue = "all")) + String enforcement, + @RequestParam(name = "validationType", required = false) + @Parameter( + description = "Filter by validation type (comma-separated for multiple values, e.g., NAME SQUATTING, BLOCKLIST, SECRET)", + style = ParameterStyle.FORM, + explode = Explode.FALSE, + array = @ArraySchema(schema = @Schema(type = "string", example = "NAME SQUATTING")) + ) + List validationType, + @RequestParam(name = "threatScannerName", required = false) + @Parameter( + description = "Filter by threat scanner name (comma-separated for multiple values, e.g., Yara, ClamAV)", + style = ParameterStyle.FORM, + explode = Explode.FALSE, + array = @ArraySchema(schema = @Schema(type = "string", example = "Yara")) + ) + List threatScannerName + ) { + try { + admins.checkAdminUser(); + + var stats = new ScanStatisticsJson(); + + // Parse all filter parameters + var startedFrom = parseUtcDateTime(dateStartedFrom, "dateStartedFrom"); + var startedTo = parseUtcDateTime(dateStartedTo, "dateStartedTo"); + var enforcementFilter = parseEnforcementFilter(enforcement); + var checkTypes = parseValidationTypes(validationType); + var scannerNames = parseScannerNames(threatScannerName); + + boolean hasDateFilter = startedFrom != null || startedTo != null; + boolean hasEnforcementFilter = enforcementFilter != EnforcementFilter.ALL; + boolean hasCheckTypesFilter = checkTypes != null && !checkTypes.isEmpty(); + boolean hasScannerNamesFilter = scannerNames != null && !scannerNames.isEmpty(); + boolean hasAnyFilter = hasDateFilter || hasEnforcementFilter || hasCheckTypesFilter || hasScannerNamesFilter; + + if (!hasAnyFilter) { + // Fast path: simple DB counts when no filtering is requested + stats.setSTARTED(repositories.countExtensionScansByStatus(ScanStatus.STARTED)); + stats.setVALIDATING(repositories.countExtensionScansByStatus(ScanStatus.VALIDATING)); + stats.setSCANNING(repositories.countExtensionScansByStatus(ScanStatus.SCANNING)); + stats.setPASSED(repositories.countExtensionScansByStatus(ScanStatus.PASSED)); + stats.setQUARANTINED(repositories.countExtensionScansByStatus(ScanStatus.QUARANTINED)); + stats.setAUTO_REJECTED(repositories.countExtensionScansByStatus(ScanStatus.REJECTED)); + stats.setERROR(repositories.countExtensionScansByStatus(ScanStatus.ERRORED)); + stats.setALLOWED((int) repositories.countAdminScanDecisions(AdminScanDecision.ALLOWED)); + stats.setBLOCKED((int) repositories.countAdminScanDecisions(AdminScanDecision.BLOCKED)); + } else { + // Use unified count query with all filters + Boolean enforcedOnly = switch (enforcementFilter) { + case ENFORCED -> true; + case NOT_ENFORCED -> false; + case ALL -> null; + }; + + // Count each status with all filters applied + stats.setSTARTED((int) repositories.countScansForStatistics( + ScanStatus.STARTED, startedFrom, startedTo, checkTypes, scannerNames, enforcedOnly)); + stats.setVALIDATING((int) repositories.countScansForStatistics( + ScanStatus.VALIDATING, startedFrom, startedTo, checkTypes, scannerNames, enforcedOnly)); + stats.setSCANNING((int) repositories.countScansForStatistics( + ScanStatus.SCANNING, startedFrom, startedTo, checkTypes, scannerNames, enforcedOnly)); + stats.setPASSED((int) repositories.countScansForStatistics( + ScanStatus.PASSED, startedFrom, startedTo, checkTypes, scannerNames, enforcedOnly)); + stats.setQUARANTINED((int) repositories.countScansForStatistics( + ScanStatus.QUARANTINED, startedFrom, startedTo, checkTypes, scannerNames, enforcedOnly)); + stats.setAUTO_REJECTED((int) repositories.countScansForStatistics( + ScanStatus.REJECTED, startedFrom, startedTo, checkTypes, scannerNames, enforcedOnly)); + stats.setERROR((int) repositories.countScansForStatistics( + ScanStatus.ERRORED, startedFrom, startedTo, checkTypes, scannerNames, enforcedOnly)); + + // Admin decision counts with all filters applied + stats.setALLOWED((int) repositories.countAdminDecisionsForStatistics( + AdminScanDecision.ALLOWED, startedFrom, startedTo, checkTypes, scannerNames, enforcedOnly)); + stats.setBLOCKED((int) repositories.countAdminDecisionsForStatistics( + AdminScanDecision.BLOCKED, startedFrom, startedTo, checkTypes, scannerNames, enforcedOnly)); + } + + // NEEDS_REVIEW = quarantined scans without a decision + var quarantinedCount = stats.getQUARANTINED(); + var decidedCount = stats.getALLOWED() + stats.getBLOCKED(); + stats.setNEEDS_REVIEW(Math.max(0, quarantinedCount - decidedCount)); + + return ResponseEntity.ok(stats); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(ScanStatisticsJson.class); + } + } + + /** + * Enforcement filter for Scan API /scans/counts. + * + * - ALL: no filtering + * - ENFORCED: scans that have at least one enforced validation or threat + * - NOT_ENFORCED: scans that have at least one non-enforced validation or threat + * + * Threat scanning enforcement will be added when threats are persisted. + */ + private enum EnforcementFilter { + ENFORCED, + NOT_ENFORCED, + ALL + } + + private EnforcementFilter parseEnforcementFilter(String enforcement) { + if (enforcement == null || enforcement.isBlank()) { + return EnforcementFilter.ALL; + } + return switch (enforcement.trim().toLowerCase(Locale.ROOT)) { + case "enforced" -> EnforcementFilter.ENFORCED; + case "notenforced" -> EnforcementFilter.NOT_ENFORCED; + case "all" -> EnforcementFilter.ALL; + default -> throw new ErrorResultException( + "Parameter 'enforcement' must be one of: enforced, notEnforced, all", + HttpStatus.BAD_REQUEST + ); + }; + } + + /** + * Get all extension scans with filtering, sorting and pagination. + */ + @GetMapping( + path = "/scans", + produces = MediaType.APPLICATION_JSON_VALUE + ) + @CrossOrigin + @Operation(summary = "Get all extension scans") + @ApiResponse( + responseCode = "200", + description = "List of all scans", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + array = @ArraySchema(schema = @Schema(implementation = ScanResultJson.class)) + ) + ) + public ResponseEntity getAllScans( + @RequestParam(required = false) + @Parameter( + description = "Filter by scan status (comma-separated for multiple values)", + style = ParameterStyle.FORM, + explode = Explode.FALSE, + array = @ArraySchema(schema = @Schema( + type = "string", + allowableValues = { "STARTED", "VALIDATING", "SCANNING", "PASSED", "QUARANTINED", "AUTO REJECTED", "ERROR" }, + example = "QUARANTINED" + )) + ) + List status, + @RequestParam(required = false) + @Parameter(description = "Filter by publisher name (partial matches supported)") + String publisher, + @RequestParam(required = false) + @Parameter(description = "Filter by namespace (partial matches supported)") + String namespace, + @RequestParam(required = false) + @Parameter(description = "Filter by display name or extension name (partial matches supported)") + String name, + @RequestParam(defaultValue = "10") + @Parameter(description = "Maximal number of entries to return", schema = @Schema(type = "integer", minimum = "0", defaultValue = "10")) + int size, + @RequestParam(defaultValue = "0") + @Parameter(description = "Number of entries to skip", schema = @Schema(type = "integer", minimum = "0", defaultValue = "0")) + int offset, + @RequestParam(defaultValue = "scanEndTime") + @Parameter(description = "Field to sort by", schema = @Schema(type = "string", allowableValues = {"scanEndTime", "scanStartTime", "displayName", "publisher", "status"}, defaultValue = "scanEndTime")) + String sortBy, + @RequestParam(defaultValue = "desc") + @Parameter(description = "Sort order", schema = @Schema(type = "string", allowableValues = {"asc", "desc"}, defaultValue = "desc")) + String sortOrder, + @RequestParam(required = false) + @Parameter(description = "Filter scans started on or after this date (ISO 8601 format)") + String dateStartedFrom, + @RequestParam(required = false) + @Parameter(description = "Filter scans started on or before this date (ISO 8601 format)") + String dateStartedTo, + @RequestParam(name = "validationType", required = false) + @Parameter( + description = "Filter by validation type (comma-separated for multiple values, e.g., NAME SQUATTING, BLOCKLIST, SECRET)", + style = ParameterStyle.FORM, + explode = Explode.FALSE, + array = @ArraySchema(schema = @Schema(type = "string", example = "NAME SQUATTING")) + ) + List validationType, + @RequestParam(required = false) + @Parameter( + description = "Filter by threat scanner name (comma-separated for multiple values).", + style = ParameterStyle.FORM, + explode = Explode.FALSE, + array = @ArraySchema(schema = @Schema(type = "string", example = "Yara")) + ) + List threatScannerName, + @RequestParam(defaultValue = "all") + @Parameter( + description = "Filter by enforcement status of threats/validations", + schema = @Schema(type = "string", allowableValues = {"enforced", "notEnforced", "all"}, defaultValue = "all") + ) + String enforcement, + @RequestParam(name = "adminDecision", required = false) + @Parameter( + description = "Filter by admin decision status (comma-separated for multiple values). Use 'allowed' for scans with Allowed decision, 'blocked' for Blocked decision, 'needs-review' for scans with no decision yet.", + style = ParameterStyle.FORM, + explode = Explode.FALSE, + array = @ArraySchema(schema = @Schema(type = "string", allowableValues = {"allowed", "blocked", "needs-review"})) + ) + List adminDecision + ) { + try { + admins.checkAdminUser(); + + if (size < 0) { + throw new ErrorResultException("Parameter 'size' must be >= 0", HttpStatus.BAD_REQUEST); + } + if (offset < 0) { + throw new ErrorResultException("Parameter 'offset' must be >= 0", HttpStatus.BAD_REQUEST); + } + + var statusFilter = parseStatusFilter(status); + var normalizedPublisher = normalizeSearch(publisher); + var normalizedNamespace = normalizeSearch(namespace); + var normalizedName = normalizeSearch(name); + var ascending = normalizeSortOrder(sortOrder); + + var startedFrom = parseUtcDateTime(dateStartedFrom, "dateStartedFrom"); + var startedTo = parseUtcDateTime(dateStartedTo, "dateStartedTo"); + var enforcementFilter = parseEnforcementFilter(enforcement); + var checkTypes = parseValidationTypes(validationType); + var scannerNames = parseScannerNames(threatScannerName); + var adminDecisionFilter = parseAdminDecisionFilter(adminDecision); + + // Convert enforcement filter to Boolean for DB query + // null = no filter, true = enforced only, false = non-enforced only + Boolean enforcedOnly = switch (enforcementFilter) { + case ENFORCED -> true; + case NOT_ENFORCED -> false; + case ALL -> null; + }; + + + var sort = createSort(sortBy, ascending); + var pageNumber = offset / Math.max(size, 1); + var pageable = PageRequest.of(pageNumber, size, sort); + + var page = repositories.findScansFullyFiltered( + statusFilter.isEmpty() ? null : statusFilter, + normalizedNamespace.isEmpty() ? null : normalizedNamespace, + normalizedPublisher.isEmpty() ? null : normalizedPublisher, + normalizedName.isEmpty() ? null : normalizedName, + startedFrom, + startedTo, + checkTypes, + scannerNames, + enforcedOnly, + adminDecisionFilter, + pageable + ); + + var result = new ScanResultListJson(); + result.setTotalSize((int) page.getTotalElements()); + result.setOffset(offset); + result.setScans(page.getContent().stream() + .map(this::toScanResultJson) + .collect(Collectors.toList())); + + return ResponseEntity.ok(result); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(ScanResultListJson.class); + } + } + + /** + * Parse validation type filter into a set of check types for DB query. + * Returns null if no filter is specified. + */ + private Set parseValidationTypes(List validationType) { + if (validationType == null || validationType.isEmpty()) { + return null; + } + var types = validationType.stream() + .filter(v -> v != null && !v.isBlank()) + .map(String::trim) + .collect(Collectors.toSet()); + return types.isEmpty() ? null : types; + } + + /** + * Parse threat scanner name filter into a set of scanner names for DB query. + * Returns null if no filter is specified. + */ + private Set parseScannerNames(List scannerNames) { + if (scannerNames == null || scannerNames.isEmpty()) { + return null; + } + var names = scannerNames.stream() + .filter(v -> v != null && !v.isBlank()) + .map(String::trim) + .collect(Collectors.toSet()); + return names.isEmpty() ? null : names; + } + + /** + * Admin decision filter values for the database query. + */ + public record AdminDecisionFilterValues( + boolean filterAllowed, // Include scans with ALLOWED decision + boolean filterBlocked, // Include scans with BLOCKED decision + boolean filterNeedsReview // Include scans with no decision (needs review) + ) { + public boolean hasFilter() { + return filterAllowed || filterBlocked || filterNeedsReview; + } + } + + /** + * Parse admin decision filter into structured values for DB query. + * Returns null if no filter is specified (show all). + * + * API values: allowed, blocked, needs-review + * DB values: ALLOWED, BLOCKED, (no record = needs-review) + */ + private AdminDecisionFilterValues parseAdminDecisionFilter(List adminDecision) { + if (adminDecision == null || adminDecision.isEmpty()) { + return null; + } + + var values = adminDecision.stream() + .filter(v -> v != null && !v.isBlank()) + .map(String::trim) + .map(String::toLowerCase) + .collect(Collectors.toSet()); + + if (values.isEmpty()) { + return null; + } + + return new AdminDecisionFilterValues( + values.contains("allowed"), + values.contains("blocked"), + values.contains("needs-review") + ); + } + + /** + * Maps API sort field names to DB column names. + */ + private String toDbSortField(String sortBy) { + if (sortBy == null) { + return "completed_at"; + } + return switch (sortBy.toLowerCase(Locale.ROOT)) { + case "displayname" -> "extension_display_name"; + case "publisher" -> "publisher"; + case "status" -> "status"; + case "scanstarttime" -> "started_at"; + case "scanendtime" -> "completed_at"; + default -> throw new ErrorResultException("Unsupported sortBy value: " + sortBy, HttpStatus.BAD_REQUEST); + }; + } + + /** + * Creates a Sort object for the given field and direction. + * For scanEndTime, uses compound sort: completed_at (nulls first) then started_at as fallback. + * Nulls first ensures in-progress scans (no end date) appear at the top. + */ + private Sort createSort(String sortBy, boolean ascending) { + var direction = ascending ? Sort.Direction.ASC : Sort.Direction.DESC; + var normalizedSortBy = sortBy == null ? "scanendtime" : sortBy.toLowerCase(Locale.ROOT); + + if ("scanendtime".equals(normalizedSortBy)) { + var completedAtOrder = new Sort.Order(direction, "completed_at") + .with(Sort.NullHandling.NULLS_FIRST); + var startedAtOrder = new Sort.Order(direction, "started_at"); + return Sort.by(completedAtOrder, startedAtOrder); + } + + var dbSortField = toDbSortField(sortBy); + return Sort.by(direction, dbSortField); + } + + /** + * Returns distinct values that can be used to filter the scan list. + */ + @GetMapping( + path = "/scans/filterOptions", + produces = MediaType.APPLICATION_JSON_VALUE + ) + @CrossOrigin + @Operation(summary = "Get scan filter options") + @ApiResponse( + responseCode = "200", + description = "Lists of unique values usable for scan filtering", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ScanFilterOptionsJson.class) + ) + ) + public ResponseEntity getScanFilterOptions() { + try { + admins.checkAdminUser(); + + var options = new ScanFilterOptionsJson(); + + options.setValidationTypes(repositories.findDistinctValidationFailureCheckTypes()); + + options.setThreatScannerNames(repositories.findDistinctThreatScannerTypes()); + + return ResponseEntity.ok(options); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(ScanFilterOptionsJson.class); + } + } + + /** + * Get a specific scan by its ID. + * Returns detailed information about a single scan. + */ + @GetMapping( + path = "/scans/{scanId}", + produces = MediaType.APPLICATION_JSON_VALUE + ) + @CrossOrigin + @Operation(summary = "Get a specific scan by ID") + @ApiResponse( + responseCode = "200", + description = "Scan details", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ScanResultJson.class) + ) + ) + @ApiResponse( + responseCode = "404", + description = "Scan not found", + content = @Content() + ) + public ResponseEntity getScan( + @PathVariable @Parameter(description = "Scan ID", example = "123") long scanId + ) { + try { + admins.checkAdminUser(); + + var scan = repositories.findExtensionScan(scanId); + if (scan == null) { + throw new ErrorResultException("Scan not found: " + scanId, HttpStatus.NOT_FOUND); + } + + var result = toScanResultJson(scan); + + return ResponseEntity.ok(result); + } catch (ErrorResultException exc) { + throw new org.springframework.web.server.ResponseStatusException( + exc.getStatus() != null ? (HttpStatus) exc.getStatus() : HttpStatus.BAD_REQUEST, + exc.getMessage() + ); + } + } + + /** + * Make security decisions for one or more quarantined scans. + * Only valid for scans with QUARANTINED status. + * Pass a single scanId for individual decisions, or multiple scanIds for bulk operations. + */ + @PostMapping( + path = "/scans/decisions", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + @CrossOrigin + @Operation(summary = "Make security decisions for quarantined scans") + @ApiResponse( + responseCode = "200", + description = "Decisions processed successfully", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ScanDecisionResponseJson.class) + ) + ) + @ApiResponse( + responseCode = "400", + description = "Invalid request or scan not in quarantined status", + content = @Content() + ) + public ResponseEntity makeScanDecisions( + @RequestBody ScanDecisionRequest request + ) { + try { + var adminUser = admins.checkAdminUser(); + + if (request.getScanIds() == null || request.getScanIds().isEmpty()) { + throw new ErrorResultException("Scan IDs are required", HttpStatus.BAD_REQUEST); + } + if (request.getDecision() == null || request.getDecision().isBlank()) { + throw new ErrorResultException("Decision is required", HttpStatus.BAD_REQUEST); + } + + var decisionValue = parseDecision(request.getDecision()); + + var results = new java.util.ArrayList(); + int successful = 0; + int failed = 0; + + for (var scanIdStr : request.getScanIds()) { + try { + var scanId = Long.parseLong(scanIdStr); + var scan = repositories.findExtensionScan(scanId); + + if (scan == null) { + results.add(ScanDecisionResultJson.failure(scanIdStr, "Scan not found")); + failed++; + continue; + } + + if (scan.getStatus() != ScanStatus.QUARANTINED) { + results.add(ScanDecisionResultJson.failure(scanIdStr, + "Scan not in quarantined status: " + formatScanStatus(scan.getStatus()))); + failed++; + continue; + } + + var existingDecision = repositories.findAdminScanDecisionByScanId(scanId); + if (existingDecision != null) { + results.add(ScanDecisionResultJson.failure(scanIdStr, + "Decision already exists: " + existingDecision.getDecision())); + failed++; + continue; + } + + AdminScanDecision decision; + if (AdminScanDecision.ALLOWED.equals(decisionValue)) { + decision = AdminScanDecision.allowed(scan, adminUser); + } else { + decision = AdminScanDecision.blocked(scan, adminUser); + } + repositories.saveAdminScanDecision(decision); + + var threats = repositories.findExtensionThreats(scan).toList(); + for (var threat : threats) { + if (threat.isEnforced() && threat.getFileHash() != null) { + createOrUpdateFileDecision(threat, scan, decisionValue, adminUser); + } + } + + results.add(ScanDecisionResultJson.success(scanIdStr)); + successful++; + } catch (NumberFormatException e) { + results.add(ScanDecisionResultJson.failure(scanIdStr, "Invalid scan ID format")); + failed++; + } catch (Exception e) { + results.add(ScanDecisionResultJson.failure(scanIdStr, "Failed to create scan decision")); + failed++; + } + } + + var response = new ScanDecisionResponseJson(); + response.setProcessed(request.getScanIds().size()); + response.setSuccessful(successful); + response.setFailed(failed); + response.setResults(results); + + return ResponseEntity.ok(response); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(ScanDecisionResponseJson.class); + } + } + + private String parseDecision(String decision) { + var normalized = decision.trim().toLowerCase(Locale.ROOT); + return switch (normalized) { + case "allowed", "allow" -> AdminScanDecision.ALLOWED; + case "blocked", "block" -> AdminScanDecision.BLOCKED; + default -> throw new ErrorResultException( + "Invalid decision value: " + decision + ". Must be 'allowed' or 'blocked'", + HttpStatus.BAD_REQUEST + ); + }; + } + + /** + * Create or update a file decision for an enforced threat. + * Maps the admin's scan decision to a file-level allow/block list entry. + */ + private void createOrUpdateFileDecision( + ExtensionThreat threat, + ExtensionScan scan, + String decisionValue, + UserData adminUser + ) { + var fileHash = threat.getFileHash(); + + // Map scan decision to file decision + var fileDecisionValue = AdminScanDecision.ALLOWED.equals(decisionValue) + ? FileDecision.ALLOWED + : FileDecision.BLOCKED; + + var existingDecision = repositories.findFileDecisionByHash(fileHash); + if (existingDecision != null) { + existingDecision.setDecision(fileDecisionValue); + existingDecision.setDecidedBy(adminUser); + existingDecision.setDecidedAt(LocalDateTime.now()); + repositories.saveFileDecision(existingDecision); + return; + } + + // Create new file decision with context from threat and scan + var fileDecision = new FileDecision(); + fileDecision.setFileHash(fileHash); + fileDecision.setFileName(threat.getFileName()); + fileDecision.setFileType(threat.getFileExtension()); + fileDecision.setDecision(fileDecisionValue); + fileDecision.setDecidedBy(adminUser); + fileDecision.setDecidedAt(LocalDateTime.now()); + fileDecision.setNamespaceName(scan.getNamespaceName()); + fileDecision.setExtensionName(scan.getExtensionName()); + fileDecision.setDisplayName(scan.getExtensionDisplayName()); + fileDecision.setPublisher(scan.getPublisher()); + fileDecision.setVersion(scan.getExtensionVersion()); + fileDecision.setScan(scan); + repositories.saveFileDecision(fileDecision); + } + + /** + * Converts an ExtensionScan entity to a ScanResultJson DTO. + * Populates all fields including extension metadata, dates, and validation failures. + */ + private ScanResultJson toScanResultJson(ExtensionScan scan) { + var json = new ScanResultJson(); + + json.setId(String.valueOf(scan.getId())); + json.setNamespace(scan.getNamespaceName()); + json.setExtensionName(scan.getExtensionName()); + json.setVersion(scan.getExtensionVersion()); + json.setPublisher(scan.getPublisher()); + json.setPublisherUrl(scan.getPublisherUrl()); + if (scan.getExtensionDisplayName() != null) { + json.setDisplayName(scan.getExtensionDisplayName()); + } + json.setTargetPlatform(scan.getTargetPlatform()); + json.setUniversalTargetPlatform(scan.isUniversalTargetPlatform()); + + var version = repositories.findVersion( + scan.getExtensionVersion(), + scan.getTargetPlatform(), + scan.getExtensionName(), + scan.getNamespaceName() + ); + + if (version != null) { + populateExtensionMetadata(json, version); + } else if (json.getDisplayName() == null) { + json.setDisplayName(scan.getExtensionName()); + } + + json.setStatus(formatScanStatus(scan.getStatus())); + json.setDateScanStarted(TimeUtil.toUTCString(scan.getStartedAt())); + + if (scan.getCompletedAt() != null) { + json.setDateScanEnded(TimeUtil.toUTCString(scan.getCompletedAt())); + } + + if (scan.getStatus() == ScanStatus.QUARANTINED && scan.getCompletedAt() != null) { + json.setDateQuarantined(TimeUtil.toUTCString(scan.getCompletedAt())); + } + + if (scan.getStatus() == ScanStatus.REJECTED && scan.getCompletedAt() != null) { + json.setDateRejected(TimeUtil.toUTCString(scan.getCompletedAt())); + } + + if (scan.getErrorMessage() != null) { + json.setErrorMessage(scan.getErrorMessage()); + } + + var validationFailures = repositories.findValidationFailures(scan).toList(); + if (!validationFailures.isEmpty()) { + var failures = validationFailures.stream() + .map(this::toValidationFailureJson) + .collect(Collectors.toList()); + json.setValidationFailures(failures); + } + + var threats = repositories.findExtensionThreats(scan).toList(); + if (!threats.isEmpty()) { + var threatJsons = threats.stream() + .map(this::toThreatJson) + .collect(Collectors.toList()); + json.setThreats(threatJsons); + } + + var adminDecision = repositories.findAdminScanDecisionByScanId(scan.getId()); + if (adminDecision != null) { + json.setAdminDecision(toAdminDecisionJson(adminDecision)); + } + + return json; + } + + /** + * Converts an ExtensionThreat entity to a ThreatJson DTO. + */ + private ThreatJson toThreatJson(ExtensionThreat threat) { + var json = new ThreatJson(); + json.setId(String.valueOf(threat.getId())); + json.setType(threat.getType()); + json.setRuleName(threat.getRuleName()); + json.setReason(threat.getReason()); + json.setDateDetected(TimeUtil.toUTCString(threat.getDetectedAt())); + json.setFileName(threat.getFileName()); + json.setFileHash(threat.getFileHash()); + json.setFileExtension(threat.getFileExtension()); + json.setSeverity(threat.getSeverity()); + json.setEnforcedFlag(threat.isEnforced()); + return json; + } + + /** + * Converts an AdminScanDecision entity to an AdminDecisionJson DTO. + */ + private AdminDecisionJson toAdminDecisionJson(AdminScanDecision decision) { + var json = new AdminDecisionJson(); + // Map ALLOWED/BLOCKED to Allowed/Blocked for display + json.setDecision(AdminScanDecision.ALLOWED.equals(decision.getDecision()) ? "Allowed" : "Blocked"); + json.setDecidedBy(decision.getDecidedByName()); + json.setDateDecided(TimeUtil.toUTCString(decision.getDecidedAt())); + return json; + } + + /** + * Populates extension metadata from the ExtensionVersion entity. + * This includes display name, icon, and download URL. + * Note: Publisher is set from the scan record, not from current namespace data, + * to preserve the publisher name as it was at the time of scan. + */ + private void populateExtensionMetadata(ScanResultJson json, ExtensionVersion version) { + json.setDisplayName(version.getDisplayName()); + + var fileUrls = storageUtil.getFileUrls( + List.of(version), + UrlUtil.getBaseUrl(), + org.eclipse.openvsx.entities.FileResource.ICON, + org.eclipse.openvsx.entities.FileResource.DOWNLOAD + ); + + var files = fileUrls.get(version.getId()); + if (files != null) { + var iconUrl = files.get(org.eclipse.openvsx.entities.FileResource.ICON); + if (iconUrl != null) { + json.setExtensionIcon(iconUrl); + } + + var downloadUrl = files.get(org.eclipse.openvsx.entities.FileResource.DOWNLOAD); + if (downloadUrl != null) { + json.setDownloadUrl(downloadUrl); + } + } + } + + private ValidationFailureJson toValidationFailureJson(org.eclipse.openvsx.entities.ExtensionValidationFailure failure) { + var json = new ValidationFailureJson(); + json.setId(String.valueOf(failure.getId())); + json.setType(failure.getCheckType()); + json.setRuleName(failure.getRuleName()); + json.setReason(failure.getValidationFailureReason()); + json.setDateDetected(TimeUtil.toUTCString(failure.getDetectedAt())); + json.setEnforcedFlag(failure.isEnforced()); + return json; + } + + /** + * Formats the ScanStatus enum to the API-standard display string. + * Maps internal enum values to the display strings defined in the API spec. + */ + private String formatScanStatus(ScanStatus status) { + return switch (status) { + case STARTED -> "STARTED"; + case VALIDATING -> "VALIDATING"; + case SCANNING -> "SCANNING"; + case PASSED -> "PASSED"; + case QUARANTINED -> "QUARANTINED"; + case REJECTED -> "AUTO REJECTED"; + case ERRORED -> "ERROR"; + }; + } + + private LocalDateTime parseUtcDateTime(String raw, String paramName) { + if (raw == null || raw.isBlank()) { + return null; + } + try { + return TimeUtil.fromUTCString(raw); + } catch (Exception e) { + throw new ErrorResultException( + "Invalid ISO date-time for parameter '" + paramName + "': " + raw, + HttpStatus.BAD_REQUEST + ); + } + } + + /** + * Parses one or multiple status filter values into a set of ScanStatus values. + * + * Supports: + * - one value: status=PASSED + * - multiple values (comma-separated with explode=false): status=PASSED,ERROR + * - multiple values (repeated): status=PASSED&status=ERROR + * + */ + private Set parseStatusFilter(List status) { + if (status == null || status.isEmpty() || status.stream().allMatch(v -> v == null || v.isBlank())) { + return Set.of(); + } + + var result = new java.util.HashSet(); + for (var raw : status) { + if (raw == null || raw.isBlank()) { + continue; + } + + for (var token : raw.split(",")) { + if (token == null || token.isBlank()) { + continue; + } + result.addAll(parseSingleStatusFilter(token)); + } + } + return Set.copyOf(result); + } + + /** + * Parses a single status token (one string entry from the query list). + */ + private Set parseSingleStatusFilter(String status) { + return switch (status) { + case "STARTED" -> Set.of(ScanStatus.STARTED); + case "VALIDATING" -> Set.of(ScanStatus.VALIDATING); + case "SCANNING" -> Set.of(ScanStatus.SCANNING); + case "PASSED" -> Set.of(ScanStatus.PASSED); + case "QUARANTINED" -> Set.of(ScanStatus.QUARANTINED); + case "AUTO REJECTED" -> Set.of(ScanStatus.REJECTED); + case "ERROR" -> Set.of(ScanStatus.ERRORED); + default -> throw new ErrorResultException("Unknown status filter: " + status, HttpStatus.BAD_REQUEST); + }; + } + + private String normalizeSearch(String search) { + return search == null ? "" : search.trim().toLowerCase(Locale.ROOT); + } + + private boolean normalizeSortOrder(String sortOrder) { + if (sortOrder == null) { + return false; + } + return switch (sortOrder.toLowerCase(Locale.ROOT)) { + case "asc" -> true; + case "desc" -> false; + default -> throw new ErrorResultException("Unsupported sortOrder value: " + sortOrder, HttpStatus.BAD_REQUEST); + }; + } + +} + diff --git a/server/src/main/java/org/eclipse/openvsx/entities/AdminScanDecision.java b/server/src/main/java/org/eclipse/openvsx/entities/AdminScanDecision.java new file mode 100644 index 000000000..0985f8d4b --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/entities/AdminScanDecision.java @@ -0,0 +1,169 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.entities; + +import jakarta.persistence.*; +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * Admin decision on a quarantined extension scan. + * Stores whether an admin has allowed or blocked a quarantined extension. + * One decision per scan (unique constraint on scan_id). + */ +@Entity +@Table(name = "admin_scan_decision") +public class AdminScanDecision implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** Decision value: extension was allowed to be published */ + public static final String ALLOWED = "ALLOWED"; + + /** Decision value: extension was blocked from publishing */ + public static final String BLOCKED = "BLOCKED"; + + @Id + @GeneratedValue(generator = "adminScanDecisionSeq") + @SequenceGenerator(name = "adminScanDecisionSeq", sequenceName = "admin_scan_decision_seq", allocationSize = 1) + private long id; + + /** Reference to the scan this decision applies to (unique - one decision per scan) */ + @OneToOne + @JoinColumn(name = "scan_id", nullable = false, unique = true) + private ExtensionScan scan; + + /** Decision: ALLOWED or BLOCKED */ + @Column(name = "decision", nullable = false, length = 20) + private String decision; + + /** Admin who made the decision */ + @ManyToOne + @JoinColumn(name = "decided_by_id", nullable = false) + private UserData decidedBy; + + /** When the decision was made */ + @Column(name = "decided_at", nullable = false) + private LocalDateTime decidedAt; + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public ExtensionScan getScan() { + return scan; + } + + public void setScan(ExtensionScan scan) { + this.scan = scan; + } + + public String getDecision() { + return decision; + } + + public void setDecision(String decision) { + this.decision = decision; + } + + public UserData getDecidedBy() { + return decidedBy; + } + + public void setDecidedBy(UserData decidedBy) { + this.decidedBy = decidedBy; + } + + /** Convenience method to get the admin's email (or login name if email is empty) for API responses */ + public String getDecidedByName() { + if (decidedBy == null) { + return null; + } + var email = decidedBy.getEmail(); + return (email != null && !email.isBlank()) ? email : decidedBy.getLoginName(); + } + + public LocalDateTime getDecidedAt() { + return decidedAt; + } + + public void setDecidedAt(LocalDateTime decidedAt) { + this.decidedAt = decidedAt; + } + + /** Returns true if this decision allows the extension */ + public boolean isAllowed() { + return ALLOWED.equals(decision); + } + + /** Returns true if this decision blocks the extension */ + public boolean isBlocked() { + return BLOCKED.equals(decision); + } + + /** + * Factory method to create an allowed decision. + */ + public static AdminScanDecision allowed(ExtensionScan scan, UserData decidedBy) { + var decision = new AdminScanDecision(); + decision.setScan(scan); + decision.setDecision(ALLOWED); + decision.setDecidedBy(decidedBy); + decision.setDecidedAt(LocalDateTime.now()); + return decision; + } + + /** + * Factory method to create a blocked decision. + */ + public static AdminScanDecision blocked(ExtensionScan scan, UserData decidedBy) { + var decision = new AdminScanDecision(); + decision.setScan(scan); + decision.setDecision(BLOCKED); + decision.setDecidedBy(decidedBy); + decision.setDecidedAt(LocalDateTime.now()); + return decision; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AdminScanDecision that = (AdminScanDecision) o; + return id == that.id + && Objects.equals(getScanId(scan), getScanId(that.scan)) + && Objects.equals(decision, that.decision) + && Objects.equals(getUserId(decidedBy), getUserId(that.decidedBy)); + } + + @Override + public int hashCode() { + return Objects.hash(id, getScanId(scan), decision, getUserId(decidedBy)); + } + + private Long getScanId(ExtensionScan scan) { + return scan != null ? scan.getId() : null; + } + + private Long getUserId(UserData user) { + return user != null ? user.getId() : null; + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/entities/ExtensionScan.java b/server/src/main/java/org/eclipse/openvsx/entities/ExtensionScan.java new file mode 100644 index 000000000..af91f1a28 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/entities/ExtensionScan.java @@ -0,0 +1,263 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.entities; + +import jakarta.persistence.*; +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Main scan record that tracks the complete lifecycle of an extension + * from upload through validation, scanning, and admin review. + */ +@Entity +@Table(name = "extension_scan") +public class ExtensionScan implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(generator = "extensionScanSeq") + @SequenceGenerator(name = "extensionScanSeq", sequenceName = "extension_scan_seq", allocationSize = 1) + private long id; + + /** + * Raw metadata about the extension being scanned. + * These are stored as strings (not foreign keys) so scan history is preserved + * even if the extension/namespace/version is deleted. + */ + @Column(name = "namespace_name", nullable = false, length = 255) + private String namespaceName; + + @Column(name = "extension_name", nullable = false, length = 255) + private String extensionName; + + @Column(name = "extension_version", nullable = false, length = 100) + private String extensionVersion; + + @Column(name = "target_platform", nullable = false, length = 255) + private String targetPlatform; + + /** Login name of user account that published the extension */ + @Column(name = "publisher", nullable = false, length = 255) + private String publisher; + + /** Profile URL of user account that published the extension (e.g., GitHub profile) */ + @Column(name = "publisher_url", length = 255) + private String publisherUrl; + + /** Display name captured at scan creation to survive fast-fail rollbacks */ + @Column(name = "extension_display_name", length = 255) + private String extensionDisplayName; + + /** Target platform flag captured at scan creation */ + @Column(name = "universal_target_platform") + private boolean universalTargetPlatform; + + /** Timestamp when the scan was initiated */ + @Column(name = "started_at", nullable = false) + private LocalDateTime startedAt; + + /** Timestamp when the scan was completed (null if still in progress) */ + @Column(name = "completed_at") + private LocalDateTime completedAt; + + /** Current status of the scan process */ + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ScanStatus status; + + /** Error message if scan encountered an error (null otherwise) */ + @Column(name = "error_message", length = 2048) + private String errorMessage; + + /** List of validation failures detected during pre-validation */ + @OneToMany(mappedBy = "scan", cascade = CascadeType.ALL, orphanRemoval = true) + private List validationFailures = new ArrayList<>(); + + /** List of threats detected by security scanners */ + @OneToMany(mappedBy = "scan", cascade = CascadeType.ALL, orphanRemoval = true) + private List threats = new ArrayList<>(); + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getNamespaceName() { + return namespaceName; + } + + public void setNamespaceName(String namespaceName) { + this.namespaceName = namespaceName; + } + + public String getExtensionName() { + return extensionName; + } + + public void setExtensionName(String extensionName) { + this.extensionName = extensionName; + } + + public String getExtensionVersion() { + return extensionVersion; + } + + public void setExtensionVersion(String extensionVersion) { + this.extensionVersion = extensionVersion; + } + + public String getTargetPlatform() { + return targetPlatform; + } + + public void setTargetPlatform(String targetPlatform) { + this.targetPlatform = targetPlatform; + } + + public String getPublisher() { + return publisher; + } + + public void setPublisher(String publisher) { + this.publisher = publisher; + } + + public String getPublisherUrl() { + return publisherUrl; + } + + public void setPublisherUrl(String publisherUrl) { + this.publisherUrl = publisherUrl; + } + + public String getExtensionDisplayName() { + return extensionDisplayName; + } + + public void setExtensionDisplayName(String extensionDisplayName) { + this.extensionDisplayName = extensionDisplayName; + } + + public boolean isUniversalTargetPlatform() { + return universalTargetPlatform; + } + + public void setUniversalTargetPlatform(boolean universalTargetPlatform) { + this.universalTargetPlatform = universalTargetPlatform; + } + + public LocalDateTime getStartedAt() { + return startedAt; + } + + public void setStartedAt(LocalDateTime startedAt) { + this.startedAt = startedAt; + } + + public LocalDateTime getCompletedAt() { + return completedAt; + } + + public void setCompletedAt(LocalDateTime completedAt) { + this.completedAt = completedAt; + } + + public ScanStatus getStatus() { + return status; + } + + public boolean isCompleted() { + return status.isCompleted(); + } + + public void setStatus(ScanStatus status) { + this.status = status; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public List getValidationFailures() { + return validationFailures; + } + + public void setValidationFailures(List validationFailures) { + this.validationFailures = validationFailures; + } + + public void addValidationFailure(ExtensionValidationFailure failure) { + validationFailures.add(failure); + failure.setScan(this); + } + + public boolean hasValidationFailures() { + return validationFailures != null && !validationFailures.isEmpty(); + } + + public List getThreats() { + return threats; + } + + public void setThreats(List threats) { + this.threats = threats; + } + + public void addThreat(ExtensionThreat threat) { + threats.add(threat); + threat.setScan(this); + } + + public boolean hasThreats() { + return threats != null && !threats.isEmpty(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ExtensionScan that = (ExtensionScan) o; + return id == that.id + && Objects.equals(namespaceName, that.namespaceName) + && Objects.equals(extensionName, that.extensionName) + && Objects.equals(extensionVersion, that.extensionVersion) + && Objects.equals(targetPlatform, that.targetPlatform) + && Objects.equals(publisher, that.publisher) + && Objects.equals(publisherUrl, that.publisherUrl) + && Objects.equals(startedAt, that.startedAt) + && Objects.equals(completedAt, that.completedAt) + && status == that.status + && Objects.equals(errorMessage, that.errorMessage); + } + + @Override + public int hashCode() { + return Objects.hash(id, namespaceName, extensionName, extensionVersion, targetPlatform, + publisher, publisherUrl, startedAt, completedAt, status, errorMessage); + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/entities/ExtensionThreat.java b/server/src/main/java/org/eclipse/openvsx/entities/ExtensionThreat.java new file mode 100644 index 000000000..ce32ac472 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/entities/ExtensionThreat.java @@ -0,0 +1,213 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.entities; + +import jakarta.persistence.*; +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * Threat detection result from security scanners. + * Each row represents one file flagged by one scanner. + */ +@Entity +@Table(name = "extension_threat") +public class ExtensionThreat implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(generator = "extensionThreatSeq") + @SequenceGenerator(name = "extensionThreatSeq", sequenceName = "extension_threat_seq", allocationSize = 1) + private long id; + + /** Reference to the parent scan that this threat belongs to */ + @ManyToOne + @JoinColumn(name = "scan_id", nullable = false) + private ExtensionScan scan; + + /** Path to file within extension package */ + @Column(name = "file_name", nullable = false, length = 1024) + private String fileName; + + /** SHA256 hash of the flagged file */ + @Column(name = "file_hash", nullable = false, length = 128) + private String fileHash; + + /** File extension */ + @Column(name = "file_extension", length = 50) + private String fileExtension; + + /** Type of security scanner */ + @Column(name = "scanner_type", nullable = false, length = 100) + private String type; + + /** Name of scanner rule that triggered detection */ + @Column(name = "rule_name", nullable = false, length = 255) + private String ruleName; + + /** Human-readable reason for threat detection */ + @Column(name = "reason", length = 2048) + private String reason; + + /** Severity level of the threat (e.g., "Critical", "High", "Medium", "Low") */ + @Column(name = "severity", length = 50) + private String severity; + + /** Whether this failure was enforced at the time it was detected. */ + @Column(name = "enforced", nullable = false) + private boolean enforced = true; + + /** Timestamp when threat was detected */ + @Column(name = "detected_at", nullable = false) + private LocalDateTime detectedAt; + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public ExtensionScan getScan() { + return scan; + } + + public void setScan(ExtensionScan scan) { + this.scan = scan; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public String getFileHash() { + return fileHash; + } + + public void setFileHash(String fileHash) { + this.fileHash = fileHash; + } + + public String getFileExtension() { + return fileExtension; + } + + public void setFileExtension(String fileExtension) { + this.fileExtension = fileExtension; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getRuleName() { + return ruleName; + } + + public void setRuleName(String ruleName) { + this.ruleName = ruleName; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public String getSeverity() { + return severity; + } + + public void setSeverity(String severity) { + this.severity = severity; + } + + public boolean isEnforced() { + return enforced; + } + + public void setEnforced(boolean enforced) { + this.enforced = enforced; + } + + public LocalDateTime getDetectedAt() { + return detectedAt; + } + + public void setDetectedAt(LocalDateTime detectedAt) { + this.detectedAt = detectedAt; + } + + /** + * Factory method to create a threat detection record. + */ + public static ExtensionThreat create( + String fileName, + String fileHash, + String fileExtension, + String type, + String ruleName, + String reason, + String severity + ) { + var threat = new ExtensionThreat(); + threat.setFileName(fileName); + threat.setFileHash(fileHash); + threat.setFileExtension(fileExtension); + threat.setType(type); + threat.setRuleName(ruleName); + threat.setReason(reason); + threat.setSeverity(severity); + threat.setEnforced(true); + threat.setDetectedAt(LocalDateTime.now()); + return threat; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ExtensionThreat that = (ExtensionThreat) o; + return id == that.id + && Objects.equals(getScanId(scan), getScanId(that.scan)) + && Objects.equals(fileName, that.fileName) + && Objects.equals(fileHash, that.fileHash) + && Objects.equals(type, that.type) + && Objects.equals(ruleName, that.ruleName); + } + + @Override + public int hashCode() { + return Objects.hash(id, getScanId(scan), fileName, fileHash, type, ruleName); + } + + private Long getScanId(ExtensionScan scan) { + return scan != null ? scan.getId() : null; + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/entities/ExtensionValidationFailure.java b/server/src/main/java/org/eclipse/openvsx/entities/ExtensionValidationFailure.java new file mode 100644 index 000000000..42c3e2236 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/entities/ExtensionValidationFailure.java @@ -0,0 +1,163 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.entities; + +import jakarta.persistence.*; +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * Records validation failures detected during pre-validation phase. + */ +@Entity +@Table(name = "extension_validation_failure") +public class ExtensionValidationFailure implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(generator = "extensionValidationFailureSeq") + @SequenceGenerator(name = "extensionValidationFailureSeq", sequenceName = "extension_validation_failure_seq", allocationSize = 1) + private long id; + + /** Reference to the parent scan that this failure belongs to */ + @ManyToOne + @JoinColumn(name = "scan_id", nullable = false) + private ExtensionScan scan; + + /** + * Type of validation check that failed. + * This is a flexible string field to allow new validation types without code changes. + * Use CHECK_TYPE constants from ValidationCheck implementations. + */ + @Column(name = "validation_type", nullable = false, length = 100) + private String checkType; + + /** Name of the specific validation rule that failed */ + @Column(name = "rule_name", nullable = false, length = 255) + private String ruleName; + + /** Detailed explanation of why the validation failed */ + @Column(name = "validation_failure_reason", nullable = false, length = 1024) + private String validationFailureReason; + + /** Whether this failure was enforced at the time it was detected. */ + @Column(name = "enforced", nullable = false) + private boolean enforced = true; + + /** Timestamp when the validation failure was detected */ + @Column(name = "detected_at", nullable = false) + private LocalDateTime detectedAt; + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public ExtensionScan getScan() { + return scan; + } + + public void setScan(ExtensionScan scan) { + this.scan = scan; + } + + public String getCheckType() { + return checkType; + } + + public void setCheckType(String checkType) { + this.checkType = checkType; + } + + public String getRuleName() { + return ruleName; + } + + public void setRuleName(String ruleName) { + this.ruleName = ruleName; + } + + public String getValidationFailureReason() { + return validationFailureReason; + } + + public void setValidationFailureReason(String validationFailureReason) { + this.validationFailureReason = validationFailureReason; + } + + public boolean isEnforced() { + return enforced; + } + + public void setEnforced(boolean enforced) { + this.enforced = enforced; + } + + public LocalDateTime getDetectedAt() { + return detectedAt; + } + + public void setDetectedAt(LocalDateTime detectedAt) { + this.detectedAt = detectedAt; + } + + /** + * Factory method to create a validation failure with a specific check type. + * Use CHECK_TYPE constants from ValidationCheck implementations. + */ + public static ExtensionValidationFailure create( + String checkType, + String ruleName, + String reason + ) { + var failure = new ExtensionValidationFailure(); + failure.setCheckType(checkType); + failure.setRuleName(ruleName); + failure.setValidationFailureReason(reason); + failure.setEnforced(true); + failure.setDetectedAt(LocalDateTime.now()); + return failure; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ExtensionValidationFailure that = (ExtensionValidationFailure) o; + return id == that.id + && Objects.equals(getId(scan), getId(that.scan)) + && Objects.equals(checkType, that.checkType) + && Objects.equals(ruleName, that.ruleName) + && Objects.equals(validationFailureReason, that.validationFailureReason) + && enforced == that.enforced + && Objects.equals(detectedAt, that.detectedAt); + } + + @Override + public int hashCode() { + return Objects.hash(id, getId(scan), checkType, ruleName, + validationFailureReason, enforced, detectedAt); + } + + private Long getId(ExtensionScan scan) { + return scan != null ? scan.getId() : null; + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/entities/FileDecision.java b/server/src/main/java/org/eclipse/openvsx/entities/FileDecision.java new file mode 100644 index 000000000..c5b6cd552 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/entities/FileDecision.java @@ -0,0 +1,247 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.entities; + +import jakarta.persistence.*; +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * Allow list / block list entry for individual files (by SHA256 hash). + * Used to approve or block specific file content across all future extensions. + * One decision per file hash (unique constraint on file_hash). + */ +@Entity +@Table(name = "file_decision") +public class FileDecision implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** Decision value: file is allowed */ + public static final String ALLOWED = "ALLOWED"; + + /** Decision value: file is blocked */ + public static final String BLOCKED = "BLOCKED"; + + @Id + @GeneratedValue(generator = "fileDecisionSeq") + @SequenceGenerator(name = "fileDecisionSeq", sequenceName = "file_decision_seq", allocationSize = 1) + private long id; + + /** SHA256 hash uniquely identifying the file content (unique - one decision per hash) */ + @Column(name = "file_hash", nullable = false, unique = true, length = 128) + private String fileHash; + + /** Original file name for reference */ + @Column(name = "file_name", length = 1024) + private String fileName; + + /** File extension/type */ + @Column(name = "file_type", length = 50) + private String fileType; + + /** Decision: ALLOWED or BLOCKED */ + @Column(name = "decision", nullable = false, length = 20) + private String decision; + + /** Admin who made the decision */ + @ManyToOne + @JoinColumn(name = "decided_by_id", nullable = false) + private UserData decidedBy; + + /** When the decision was made */ + @Column(name = "decided_at", nullable = false) + private LocalDateTime decidedAt; + + /** Extension display name where file was first encountered */ + @Column(name = "display_name", length = 255) + private String displayName; + + /** Namespace name where file was first encountered */ + @Column(name = "namespace_name", length = 255) + private String namespaceName; + + /** Extension name where file was first encountered */ + @Column(name = "extension_name", length = 255) + private String extensionName; + + /** Publisher name where file was first encountered */ + @Column(name = "publisher", length = 255) + private String publisher; + + /** Version where file was first encountered */ + @Column(name = "version", length = 100) + private String version; + + /** Optional link to the scan that triggered this decision (nullable) */ + @ManyToOne + @JoinColumn(name = "scan_id") + private ExtensionScan scan; + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getFileHash() { + return fileHash; + } + + public void setFileHash(String fileHash) { + this.fileHash = fileHash; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public String getFileType() { + return fileType; + } + + public void setFileType(String fileType) { + this.fileType = fileType; + } + + public String getDecision() { + return decision; + } + + public void setDecision(String decision) { + this.decision = decision; + } + + public UserData getDecidedBy() { + return decidedBy; + } + + public void setDecidedBy(UserData decidedBy) { + this.decidedBy = decidedBy; + } + + public String getDecidedByName() { + if (decidedBy == null) { + return null; + } + var email = decidedBy.getEmail(); + return (email != null && !email.isBlank()) ? email : decidedBy.getLoginName(); + } + + public LocalDateTime getDecidedAt() { + return decidedAt; + } + + public void setDecidedAt(LocalDateTime decidedAt) { + this.decidedAt = decidedAt; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getNamespaceName() { + return namespaceName; + } + + public void setNamespaceName(String namespaceName) { + this.namespaceName = namespaceName; + } + + public String getExtensionName() { + return extensionName; + } + + public void setExtensionName(String extensionName) { + this.extensionName = extensionName; + } + + public String getPublisher() { + return publisher; + } + + public void setPublisher(String publisher) { + this.publisher = publisher; + } + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } + + public ExtensionScan getScan() { + return scan; + } + + public void setScan(ExtensionScan scan) { + this.scan = scan; + } + + public boolean isAllowed() { + return ALLOWED.equals(decision); + } + + public boolean isBlocked() { + return BLOCKED.equals(decision); + } + + public static FileDecision allowed(String fileHash, UserData decidedBy) { + var decision = new FileDecision(); + decision.setFileHash(fileHash); + decision.setDecision(ALLOWED); + decision.setDecidedBy(decidedBy); + decision.setDecidedAt(LocalDateTime.now()); + return decision; + } + + public static FileDecision blocked(String fileHash, UserData decidedBy) { + var decision = new FileDecision(); + decision.setFileHash(fileHash); + decision.setDecision(BLOCKED); + decision.setDecidedBy(decidedBy); + decision.setDecidedAt(LocalDateTime.now()); + return decision; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FileDecision that = (FileDecision) o; + return id == that.id + && Objects.equals(fileHash, that.fileHash) + && Objects.equals(decision, that.decision); + } + + @Override + public int hashCode() { + return Objects.hash(id, fileHash, decision); + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/entities/ScanStatus.java b/server/src/main/java/org/eclipse/openvsx/entities/ScanStatus.java new file mode 100644 index 000000000..08325addc --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/entities/ScanStatus.java @@ -0,0 +1,48 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.entities; + +/** + * Enum representing the status of an extension scan. + * Tracks the complete lifecycle from upload through validation and scanning. + */ +public enum ScanStatus { + /** Scan has been initiated */ + STARTED, + + /** Extension is undergoing pre-validation checks (name squatting, secrets, etc.) */ + VALIDATING, + + /** Extension is being scanned for malware/threats */ + SCANNING, + + /** Extension failed validation or scanning and was rejected */ + REJECTED, + + /** Extension flagged for admin review due to potential issues */ + QUARANTINED, + + /** Extension passed all checks and is ready for publication */ + PASSED, + + /** An error occurred during the scan process */ + ERRORED; + + /** + * Returns true if this status is a completed state (no further transitions). + */ + public boolean isCompleted() { + return this == REJECTED || this == QUARANTINED || this == PASSED || this == ERRORED; + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/json/AdminDecisionJson.java b/server/src/main/java/org/eclipse/openvsx/json/AdminDecisionJson.java new file mode 100644 index 000000000..67e8e764d --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/AdminDecisionJson.java @@ -0,0 +1,59 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema( + name = "AdminDecision", + description = "Admin decision on a quarantined extension" +) +@JsonInclude(Include.NON_NULL) +public class AdminDecisionJson { + + @Schema(description = "Manual security decision for quarantined extension") + private String decision; + + @Schema(description = "Admin who made the decision") + private String decidedBy; + + @Schema(description = "When the admin decision was made (UTC)") + private String dateDecided; + + public String getDecision() { + return decision; + } + + public void setDecision(String decision) { + this.decision = decision; + } + + public String getDecidedBy() { + return decidedBy; + } + + public void setDecidedBy(String decidedBy) { + this.decidedBy = decidedBy; + } + + public String getDateDecided() { + return dateDecided; + } + + public void setDateDecided(String dateDecided) { + this.dateDecided = dateDecided; + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/json/FileDecisionCountsJson.java b/server/src/main/java/org/eclipse/openvsx/json/FileDecisionCountsJson.java new file mode 100644 index 000000000..516496487 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/FileDecisionCountsJson.java @@ -0,0 +1,59 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema( + name = "FileDecisionCounts", + description = "Counts of allowed and blocked files" +) +@JsonInclude(Include.NON_NULL) +public class FileDecisionCountsJson extends ResultJson { + + @Schema(description = "Total count of allowed files") + private int allowed; + + @Schema(description = "Total count of blocked files") + private int blocked; + + @Schema(description = "Total count of all files") + private int total; + + public int getAllowed() { + return allowed; + } + + public void setAllowed(int allowed) { + this.allowed = allowed; + } + + public int getBlocked() { + return blocked; + } + + public void setBlocked(int blocked) { + this.blocked = blocked; + } + + public int getTotal() { + return total; + } + + public void setTotal(int total) { + this.total = total; + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/json/FileDecisionJson.java b/server/src/main/java/org/eclipse/openvsx/json/FileDecisionJson.java new file mode 100644 index 000000000..9c3f0fc17 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/FileDecisionJson.java @@ -0,0 +1,169 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema( + name = "FileDecision", + description = "File allow/block list decision" +) +@JsonInclude(Include.NON_NULL) +public class FileDecisionJson extends ResultJson { + + @Schema(description = "Unique identifier for the file decision") + private String id; + + @Schema(description = "ID of the scan that originally flagged this file") + private String scanId; + + @Schema(description = "Path to the file within the extension") + private String fileName; + + @Schema(description = "SHA256 hash of the file") + private String fileHash; + + @Schema(description = "File extension/type") + private String fileType; + + @Schema(description = "The admin decision for this file") + private String decision; + + @Schema(description = "Email of the admin who made the decision") + private String decidedBy; + + @Schema(description = "When the decision was made (UTC)") + private String dateDecided; + + @Schema(description = "Human-readable name of the extension containing this file") + private String displayName; + + @Schema(description = "Extension namespace") + private String namespace; + + @Schema(description = "Technical name of the extension") + private String extensionName; + + @Schema(description = "Publisher name") + private String publisher; + + @Schema(description = "Extension version when decision was made") + private String version; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getScanId() { + return scanId; + } + + public void setScanId(String scanId) { + this.scanId = scanId; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public String getFileHash() { + return fileHash; + } + + public void setFileHash(String fileHash) { + this.fileHash = fileHash; + } + + public String getFileType() { + return fileType; + } + + public void setFileType(String fileType) { + this.fileType = fileType; + } + + public String getDecision() { + return decision; + } + + public void setDecision(String decision) { + this.decision = decision; + } + + public String getDecidedBy() { + return decidedBy; + } + + public void setDecidedBy(String decidedBy) { + this.decidedBy = decidedBy; + } + + public String getDateDecided() { + return dateDecided; + } + + public void setDateDecided(String dateDecided) { + this.dateDecided = dateDecided; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getNamespace() { + return namespace; + } + + public void setNamespace(String namespace) { + this.namespace = namespace; + } + + public String getExtensionName() { + return extensionName; + } + + public void setExtensionName(String extensionName) { + this.extensionName = extensionName; + } + + public String getPublisher() { + return publisher; + } + + public void setPublisher(String publisher) { + this.publisher = publisher; + } + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/json/FileDecisionListJson.java b/server/src/main/java/org/eclipse/openvsx/json/FileDecisionListJson.java new file mode 100644 index 000000000..293cf45ee --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/FileDecisionListJson.java @@ -0,0 +1,74 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +@Schema( + name = "FilesResponse", + description = "Paginated list of file decisions" +) +@JsonInclude(Include.NON_NULL) +public class FileDecisionListJson extends ResultJson { + + public static FileDecisionListJson error(String message) { + var result = new FileDecisionListJson(); + result.setError(message); + return result; + } + + @Schema(description = "Number of skipped entries") + @NotNull + @Min(0) + private int offset; + + @Schema(description = "Total number of files matching the query") + @NotNull + @Min(0) + private int totalSize; + + @Schema(description = "List of file decisions") + @NotNull + private List files; + + public int getOffset() { + return offset; + } + + public void setOffset(int offset) { + this.offset = offset; + } + + public int getTotalSize() { + return totalSize; + } + + public void setTotalSize(int totalSize) { + this.totalSize = totalSize; + } + + public List getFiles() { + return files; + } + + public void setFiles(List files) { + this.files = files; + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/json/FileDecisionRequest.java b/server/src/main/java/org/eclipse/openvsx/json/FileDecisionRequest.java new file mode 100644 index 000000000..3867a72a8 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/FileDecisionRequest.java @@ -0,0 +1,46 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** + * Request types for file decision operations. + */ +public final class FileDecisionRequest { + + private FileDecisionRequest() {} + + @Schema( + name = "FileDecisionRequest", + description = "Request body for creating/updating file decisions" + ) + public record Create( + @Schema(description = "List of file hashes to apply the decision to") + List fileHashes, + + @Schema(description = "Decision to apply: 'allowed' or 'blocked'") + String decision + ) {} + + @Schema( + name = "FileDecisionDeleteRequest", + description = "Request body for deleting file decisions" + ) + public record Delete( + @Schema(description = "List of file IDs to delete") + List fileIds + ) {} +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/FileDecisionResponseJson.java b/server/src/main/java/org/eclipse/openvsx/json/FileDecisionResponseJson.java new file mode 100644 index 000000000..6b1a13fcb --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/FileDecisionResponseJson.java @@ -0,0 +1,85 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** + * Response types for file decision operations. + */ +public final class FileDecisionResponseJson { + + private FileDecisionResponseJson() {} + + @Schema( + name = "FileDecisionResponse", + description = "Response for file decision create/update operations" + ) + @JsonInclude(Include.NON_NULL) + public static class Create extends ResultJson { + + @Schema(description = "Total number of file hashes processed", example = "5") + private int processed; + + @Schema(description = "Number of decisions applied successfully", example = "4") + private int successful; + + @Schema(description = "Number of decisions that failed", example = "1") + private int failed; + + @Schema(description = "Detailed results for each file hash") + private List results; + + public int getProcessed() { return processed; } + public void setProcessed(int processed) { this.processed = processed; } + public int getSuccessful() { return successful; } + public void setSuccessful(int successful) { this.successful = successful; } + public int getFailed() { return failed; } + public void setFailed(int failed) { this.failed = failed; } + public List getResults() { return results; } + public void setResults(List results) { this.results = results; } + } + + @Schema( + name = "FileDecisionDeleteResponse", + description = "Response for file decision delete operations" + ) + @JsonInclude(Include.NON_NULL) + public static class Delete extends ResultJson { + + @Schema(description = "Total number of file IDs processed") + private int processed; + + @Schema(description = "Number of deletions completed successfully") + private int successful; + + @Schema(description = "Number of deletions that failed") + private int failed; + + @Schema(description = "Detailed results for each file ID") + private List results; + + public int getProcessed() { return processed; } + public void setProcessed(int processed) { this.processed = processed; } + public int getSuccessful() { return successful; } + public void setSuccessful(int successful) { this.successful = successful; } + public int getFailed() { return failed; } + public void setFailed(int failed) { this.failed = failed; } + public List getResults() { return results; } + public void setResults(List results) { this.results = results; } + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/FileDecisionResultJson.java b/server/src/main/java/org/eclipse/openvsx/json/FileDecisionResultJson.java new file mode 100644 index 000000000..128b8875a --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/FileDecisionResultJson.java @@ -0,0 +1,103 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * Result types for individual file decision operations. + */ +public final class FileDecisionResultJson { + + private FileDecisionResultJson() {} + + @Schema( + name = "FileDecisionResult", + description = "Individual result for a file decision create/update operation" + ) + @JsonInclude(Include.NON_NULL) + public static class Create { + + @Schema(description = "The file hash that was processed", example = "a3f5c8e9d2b1f4a6") + private String fileHash; + + @Schema(description = "Whether the operation was successful", example = "true") + private boolean success; + + @Schema(description = "Error message if the operation failed", example = "File hash not found") + private String error; + + public String getFileHash() { return fileHash; } + public void setFileHash(String fileHash) { this.fileHash = fileHash; } + public boolean isSuccess() { return success; } + public void setSuccess(boolean success) { this.success = success; } + public String getError() { return error; } + public void setError(String error) { this.error = error; } + + public static Create success(String fileHash) { + var result = new Create(); + result.setFileHash(fileHash); + result.setSuccess(true); + return result; + } + + public static Create failure(String fileHash, String error) { + var result = new Create(); + result.setFileHash(fileHash); + result.setSuccess(false); + result.setError(error); + return result; + } + } + + @Schema( + name = "FileDecisionDeleteResult", + description = "Individual result for a file decision delete operation" + ) + @JsonInclude(Include.NON_NULL) + public static class Delete { + + @Schema(description = "The file ID that was processed") + private Long fileId; + + @Schema(description = "Whether the deletion was successful") + private boolean success; + + @Schema(description = "Error message if the deletion failed") + private String error; + + public Long getFileId() { return fileId; } + public void setFileId(Long fileId) { this.fileId = fileId; } + public boolean isSuccess() { return success; } + public void setSuccess(boolean success) { this.success = success; } + public String getError() { return error; } + public void setError(String error) { this.error = error; } + + public static Delete success(Long fileId) { + var result = new Delete(); + result.setFileId(fileId); + result.setSuccess(true); + return result; + } + + public static Delete failure(Long fileId, String error) { + var result = new Delete(); + result.setFileId(fileId); + result.setSuccess(false); + result.setError(error); + return result; + } + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/ScanDecisionRequest.java b/server/src/main/java/org/eclipse/openvsx/json/ScanDecisionRequest.java new file mode 100644 index 000000000..619843e55 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/ScanDecisionRequest.java @@ -0,0 +1,50 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema( + name = "ScanDecisionRequest", + description = "Request body for making security decisions on quarantined scans" +) +@JsonInclude(Include.NON_NULL) +public class ScanDecisionRequest { + + @Schema(description = "List of scan IDs to apply the decision to (can be single or multiple)") + private List scanIds; + + @Schema(description = "Security decision to apply to all specified scans") + private String decision; + + public List getScanIds() { + return scanIds; + } + + public void setScanIds(List scanIds) { + this.scanIds = scanIds; + } + + public String getDecision() { + return decision; + } + + public void setDecision(String decision) { + this.decision = decision; + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/json/ScanDecisionResponseJson.java b/server/src/main/java/org/eclipse/openvsx/json/ScanDecisionResponseJson.java new file mode 100644 index 000000000..007645b55 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/ScanDecisionResponseJson.java @@ -0,0 +1,71 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema( + name = "ScanDecisionResponse", + description = "Response for security decisions on scans" +) +@JsonInclude(Include.NON_NULL) +public class ScanDecisionResponseJson extends ResultJson { + + @Schema(description = "Total number of scan IDs processed") + private int processed; + + @Schema(description = "Number of decisions applied successfully") + private int successful; + + @Schema(description = "Number of decisions that failed") + private int failed; + + @Schema(description = "Detailed results for each scan ID") + private List results; + + public int getProcessed() { + return processed; + } + + public void setProcessed(int processed) { + this.processed = processed; + } + + public int getSuccessful() { + return successful; + } + + public void setSuccessful(int successful) { + this.successful = successful; + } + + public int getFailed() { + return failed; + } + + public void setFailed(int failed) { + this.failed = failed; + } + + public List getResults() { + return results; + } + + public void setResults(List results) { + this.results = results; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/ScanDecisionResultJson.java b/server/src/main/java/org/eclipse/openvsx/json/ScanDecisionResultJson.java new file mode 100644 index 000000000..211ab8a02 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/ScanDecisionResultJson.java @@ -0,0 +1,73 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema( + name = "ScanDecisionResult", + description = "Individual result in a scan decision response" +) +@JsonInclude(Include.NON_NULL) +public class ScanDecisionResultJson { + + @Schema(description = "The scan ID that was processed") + private String scanId; + + @Schema(description = "Whether the decision was applied successfully") + private boolean success; + + @Schema(description = "Error message if the decision failed") + private String error; + + public String getScanId() { + return scanId; + } + + public void setScanId(String scanId) { + this.scanId = scanId; + } + + public boolean isSuccess() { + return success; + } + + public void setSuccess(boolean success) { + this.success = success; + } + + public String getError() { + return error; + } + + public void setError(String error) { + this.error = error; + } + + public static ScanDecisionResultJson success(String scanId) { + var result = new ScanDecisionResultJson(); + result.setScanId(scanId); + result.setSuccess(true); + return result; + } + + public static ScanDecisionResultJson failure(String scanId, String error) { + var result = new ScanDecisionResultJson(); + result.setScanId(scanId); + result.setSuccess(false); + result.setError(error); + return result; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/ScanFilterOptionsJson.java b/server/src/main/java/org/eclipse/openvsx/json/ScanFilterOptionsJson.java new file mode 100644 index 000000000..564fca670 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/ScanFilterOptionsJson.java @@ -0,0 +1,61 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.constraints.NotNull; +import java.util.List; + +/** + * Filter options for the scan listing endpoint. + * + * Why this exists: + * - The admin UI needs to know which values actually exist in the DB. + * - Returning the DISTINCT values avoids hard-coding lists client-side. + */ +@Schema( + name = "ScanFilterOptions", + description = "Lists of unique values that can be used to filter scan results" +) +@JsonInclude(Include.NON_NULL) +public class ScanFilterOptionsJson extends ResultJson { + + @Schema(description = "List of unique validation types from all scans.") + @NotNull + private List validationTypes; + + @Schema(description = "List of unique threat scanner names.") + @NotNull + private List threatScannerNames; + + public List getValidationTypes() { + return validationTypes; + } + + public void setValidationTypes(List validationTypes) { + this.validationTypes = validationTypes; + } + + public List getThreatScannerNames() { + return threatScannerNames; + } + + public void setThreatScannerNames(List threatScannerNames) { + this.threatScannerNames = threatScannerNames; + } +} + + diff --git a/server/src/main/java/org/eclipse/openvsx/json/ScanResultJson.java b/server/src/main/java/org/eclipse/openvsx/json/ScanResultJson.java new file mode 100644 index 000000000..752912eb6 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/ScanResultJson.java @@ -0,0 +1,248 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema( + name = "ScanResult", + description = "Extension scan result with status and validation details" +) +@JsonInclude(Include.NON_NULL) +public class ScanResultJson extends ResultJson { + + @Schema(description = "Unique identifier for the scan") + private String id; + + @Schema(description = "Current status of the scan") + private String status; + + @Schema(description = "URL to extension icon") + private String extensionIcon; + + @Schema(description = "Display name of the extension") + private String displayName; + + @Schema(description = "Extension namespace") + private String namespace; + + @Schema(description = "Name of the extension") + private String extensionName; + + @Schema(description = "Login name of the user who published the extension") + private String publisher; + + @Schema(description = "Profile URL of the user who published the extension") + private String publisherUrl; + + @Schema(description = "Extension version") + private String version; + + @Schema(description = "URL to download the extension package") + private String downloadUrl; + + @Schema(description = "Target platform for the scan") + private String targetPlatform; + + @Schema(description = "True if the scan target platform is universal") + private Boolean universalTargetPlatform; + + @Schema(description = "When the scan started (UTC)") + private String dateScanStarted; + + @Schema(description = "When the scan completed (UTC)") + private String dateScanEnded; + + @Schema(description = "When the extension was quarantined (UTC)") + private String dateQuarantined; + + @Schema(description = "When the extension was auto-rejected (UTC)") + private String dateRejected; + + @Schema(description = "Admin decision on quarantined extension") + private AdminDecisionJson adminDecision; + + @Schema(description = "Files flagged by security scanner") + private List threats; + + @Schema(description = "Validation failures that caused auto-rejection") + private List validationFailures; + + @Schema(description = "Error message if the scan failed with an error") + private String errorMessage; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getExtensionIcon() { + return extensionIcon; + } + + public void setExtensionIcon(String extensionIcon) { + this.extensionIcon = extensionIcon; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getNamespace() { + return namespace; + } + + public void setNamespace(String namespace) { + this.namespace = namespace; + } + + public String getExtensionName() { + return extensionName; + } + + public void setExtensionName(String extensionName) { + this.extensionName = extensionName; + } + + public String getPublisher() { + return publisher; + } + + public void setPublisher(String publisher) { + this.publisher = publisher; + } + + public String getPublisherUrl() { + return publisherUrl; + } + + public void setPublisherUrl(String publisherUrl) { + this.publisherUrl = publisherUrl; + } + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } + + public String getDownloadUrl() { + return downloadUrl; + } + + public void setDownloadUrl(String downloadUrl) { + this.downloadUrl = downloadUrl; + } + + public String getTargetPlatform() { + return targetPlatform; + } + + public void setTargetPlatform(String targetPlatform) { + this.targetPlatform = targetPlatform; + } + + public Boolean getUniversalTargetPlatform() { + return universalTargetPlatform; + } + + public void setUniversalTargetPlatform(Boolean universalTargetPlatform) { + this.universalTargetPlatform = universalTargetPlatform; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getDateScanStarted() { + return dateScanStarted; + } + + public void setDateScanStarted(String dateScanStarted) { + this.dateScanStarted = dateScanStarted; + } + + public String getDateScanEnded() { + return dateScanEnded; + } + + public void setDateScanEnded(String dateScanEnded) { + this.dateScanEnded = dateScanEnded; + } + + public String getDateQuarantined() { + return dateQuarantined; + } + + public void setDateQuarantined(String dateQuarantined) { + this.dateQuarantined = dateQuarantined; + } + + public String getDateRejected() { + return dateRejected; + } + + public void setDateRejected(String dateRejected) { + this.dateRejected = dateRejected; + } + + public AdminDecisionJson getAdminDecision() { + return adminDecision; + } + + public void setAdminDecision(AdminDecisionJson adminDecision) { + this.adminDecision = adminDecision; + } + + public List getThreats() { + return threats; + } + + public void setThreats(List threats) { + this.threats = threats; + } + + public List getValidationFailures() { + return validationFailures; + } + + public void setValidationFailures(List validationFailures) { + this.validationFailures = validationFailures; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/json/ScanResultListJson.java b/server/src/main/java/org/eclipse/openvsx/json/ScanResultListJson.java new file mode 100644 index 000000000..41a7d247f --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/ScanResultListJson.java @@ -0,0 +1,78 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +/** + * Paginated list of scan results. + * Includes paging metadata so the admin UI can request additional pages. + */ +@Schema( + name = "ScanResultList", + description = "Paginated list of extension scan results" +) +@JsonInclude(Include.NON_NULL) +public class ScanResultListJson extends ResultJson { + + public static ScanResultListJson error(String message) { + var result = new ScanResultListJson(); + result.setError(message); + return result; + } + + @Schema(description = "Number of skipped entries") + @NotNull + @Min(0) + private int offset; + + @Schema(description = "Total number of matching scan results") + @NotNull + @Min(0) + private int totalSize; + + @Schema(description = "Current page of scan results") + @NotNull + private List scans; + + public int getOffset() { + return offset; + } + + public void setOffset(int offset) { + this.offset = offset; + } + + public int getTotalSize() { + return totalSize; + } + + public void setTotalSize(int totalSize) { + this.totalSize = totalSize; + } + + public List getScans() { + return scans; + } + + public void setScans(List scans) { + this.scans = scans; + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/json/ScanStatisticsJson.java b/server/src/main/java/org/eclipse/openvsx/json/ScanStatisticsJson.java new file mode 100644 index 000000000..95808b5ca --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/ScanStatisticsJson.java @@ -0,0 +1,148 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * Aggregated scan statistics for all statuses and quarantine decisions. + */ +@Schema( + name = "ScanStatistics", + description = "Total counts for scan statuses and quarantine decisions" +) +public class ScanStatisticsJson extends ResultJson { + + @JsonIgnore + private long STARTED; + + @JsonIgnore + private long VALIDATING; + + @JsonIgnore + private long SCANNING; + + @JsonIgnore + private long PASSED; + + @JsonIgnore + private long QUARANTINED; + + @JsonIgnore + private long AUTO_REJECTED; + + @JsonIgnore + private long ERROR; + + @JsonIgnore + private long ALLOWED; + + @JsonIgnore + private long BLOCKED; + + @JsonIgnore + private long NEEDS_REVIEW; + + @JsonProperty("STARTED") + public long getSTARTED() { + return STARTED; + } + + public void setSTARTED(long STARTED) { + this.STARTED = STARTED; + } + + @JsonProperty("VALIDATING") + public long getVALIDATING() { + return VALIDATING; + } + + public void setVALIDATING(long VALIDATING) { + this.VALIDATING = VALIDATING; + } + + @JsonProperty("SCANNING") + public long getSCANNING() { + return SCANNING; + } + + public void setSCANNING(long SCANNING) { + this.SCANNING = SCANNING; + } + + @JsonProperty("PASSED") + public long getPASSED() { + return PASSED; + } + + public void setPASSED(long PASSED) { + this.PASSED = PASSED; + } + + @JsonProperty("QUARANTINED") + public long getQUARANTINED() { + return QUARANTINED; + } + + public void setQUARANTINED(long QUARANTINED) { + this.QUARANTINED = QUARANTINED; + } + + @JsonProperty("AUTO_REJECTED") + public long getAUTO_REJECTED() { + return AUTO_REJECTED; + } + + public void setAUTO_REJECTED(long AUTO_REJECTED) { + this.AUTO_REJECTED = AUTO_REJECTED; + } + + @JsonProperty("ERROR") + public long getERROR() { + return ERROR; + } + + public void setERROR(long ERROR) { + this.ERROR = ERROR; + } + + @JsonProperty("ALLOWED") + public long getALLOWED() { + return ALLOWED; + } + + public void setALLOWED(long ALLOWED) { + this.ALLOWED = ALLOWED; + } + + @JsonProperty("BLOCKED") + public long getBLOCKED() { + return BLOCKED; + } + + public void setBLOCKED(long BLOCKED) { + this.BLOCKED = BLOCKED; + } + + @JsonProperty("NEEDS_REVIEW") + public long getNEEDS_REVIEW() { + return NEEDS_REVIEW; + } + + public void setNEEDS_REVIEW(long NEEDS_REVIEW) { + this.NEEDS_REVIEW = NEEDS_REVIEW; + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/json/ThreatJson.java b/server/src/main/java/org/eclipse/openvsx/json/ThreatJson.java new file mode 100644 index 000000000..47b71962a --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/ThreatJson.java @@ -0,0 +1,150 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * JSON representation of a security threat detected during scanning. + * Represents a file flagged by security scanners. + */ +@Schema( + name = "Threat", + description = "Security threat detected by scanner" +) +@JsonInclude(Include.NON_NULL) +public class ThreatJson { + + /** Unique identifier for the threat */ + @Schema(description = "Unique identifier for the threat") + private String id; + + /** Type of security scanner that flagged this file */ + @Schema(description = "Type of security scanner that flagged this file") + private String type; + + /** Name of the scanner rule that triggered the detection */ + @Schema(description = "Name of the scanner rule that triggered the detection") + private String ruleName; + + /** Human-readable reason for threat detection */ + @Schema(description = "Human-readable reason for threat detection") + private String reason; + + /** When the threat was detected (UTC) */ + @Schema(description = "When the threat was detected (UTC)") + private String dateDetected; + + /** Path to the flagged file within the extension */ + @Schema(description = "Path to the flagged file within the extension") + private String fileName; + + /** SHA256 hash of the flagged file */ + @Schema(description = "SHA256 hash of the flagged file") + private String fileHash; + + /** File extension of the flagged file */ + @Schema(description = "File extension of the flagged file") + private String fileExtension; + + /** Severity level of the threat */ + @Schema(description = "Severity level of the threat") + private String severity; + + /** Whether this threat is enforced (affects extension status) */ + @Schema(description = "Whether this threat is enforced (affects extension status)") + private Boolean enforcedFlag; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getRuleName() { + return ruleName; + } + + public void setRuleName(String ruleName) { + this.ruleName = ruleName; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public String getDateDetected() { + return dateDetected; + } + + public void setDateDetected(String dateDetected) { + this.dateDetected = dateDetected; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public String getFileHash() { + return fileHash; + } + + public void setFileHash(String fileHash) { + this.fileHash = fileHash; + } + + public String getFileExtension() { + return fileExtension; + } + + public void setFileExtension(String fileExtension) { + this.fileExtension = fileExtension; + } + + public Boolean getEnforcedFlag() { + return enforcedFlag; + } + + public void setEnforcedFlag(Boolean enforcedFlag) { + this.enforcedFlag = enforcedFlag; + } + + public String getSeverity() { + return severity; + } + + public void setSeverity(String severity) { + this.severity = severity; + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/json/ValidationFailureJson.java b/server/src/main/java/org/eclipse/openvsx/json/ValidationFailureJson.java new file mode 100644 index 000000000..e07a35d96 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/ValidationFailureJson.java @@ -0,0 +1,102 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * JSON representation of a validation failure. + * Represents a specific validation check that failed during pre-validation. + */ +@Schema( + name = "ValidationFailure", + description = "Details of a validation check that failed" +) +@JsonInclude(Include.NON_NULL) +public class ValidationFailureJson { + + /** Unique identifier for the validation failure */ + @Schema(description = "Unique identifier for the validation failure") + private String id; + + /** Human-friendly validation type label */ + @Schema(description = "Type of validation that failed") + private String type; + + /** Specific rule/file/algorithm name */ + @Schema(description = "Specific rule name for the validation failure") + private String ruleName; + + /** Detailed explanation of why validation failed */ + @Schema(description = "Detailed explanation of why validation failed") + private String reason; + + /** When the validation failure occurred (UTC) */ + @Schema(description = "When the validation failure occurred (UTC)") + private String dateDetected; + + /** Whether this validation failure is enforced */ + @Schema(description = "Whether this validation failure is enforced") + private Boolean enforcedFlag; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getRuleName() { + return ruleName; + } + + public void setRuleName(String ruleName) { + this.ruleName = ruleName; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public String getDateDetected() { + return dateDetected; + } + + public void setDateDetected(String dateDetected) { + this.dateDetected = dateDetected; + } + + public Boolean getEnforcedFlag() { + return enforcedFlag; + } + + public void setEnforcedFlag(Boolean enforcedFlag) { + this.enforcedFlag = enforcedFlag; + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java b/server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java index 96576a55e..dc26f9aba 100644 --- a/server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java +++ b/server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java @@ -21,7 +21,7 @@ import org.eclipse.openvsx.entities.*; import org.eclipse.openvsx.extension_control.ExtensionControlService; import org.eclipse.openvsx.repositories.RepositoryService; -import org.eclipse.openvsx.search.SimilarityCheckService; +import org.eclipse.openvsx.scanning.ExtensionScanService; import org.eclipse.openvsx.util.ErrorResultException; import org.eclipse.openvsx.util.ExtensionId; import org.eclipse.openvsx.util.NamingUtil; @@ -51,7 +51,7 @@ public class PublishExtensionVersionHandler { private final UserService users; private final ExtensionValidator validator; private final ExtensionControlService extensionControl; - private final SimilarityCheckService similarityCheckService; + private final ExtensionScanService scanService; public PublishExtensionVersionHandler( PublishExtensionVersionService service, @@ -62,7 +62,7 @@ public PublishExtensionVersionHandler( UserService users, ExtensionValidator validator, ExtensionControlService extensionControl, - SimilarityCheckService similarityCheckService + ExtensionScanService scanService ) { this.service = service; this.integrityService = integrityService; @@ -72,7 +72,7 @@ public PublishExtensionVersionHandler( this.users = users; this.validator = validator; this.extensionControl = extensionControl; - this.similarityCheckService = similarityCheckService; + this.scanService = scanService; } @Transactional(rollbackOn = ErrorResultException.class) @@ -170,43 +170,6 @@ private void validateExtensionName(String namespaceName, String extensionName, S if(isMalicious(namespaceName, extensionName)) { throw new ErrorResultException(NamingUtil.toExtensionId(namespaceName, extensionName) + " is a known malicious extension"); } - - validateDistinctName(extensionName, namespaceName, displayName, user); - } - - private void validateDistinctName(String extensionName, String namespaceName, String displayName, UserData user) { - // Check if similarity checking is enabled before invoking - if (!similarityCheckService.isEnabled()) { - return; - } - - // Use SimilarityCheckService which handles config gates and "exclude owner namespaces" logic - var similarExtensions = similarityCheckService.findSimilarExtensionsForPublishing( - extensionName, - namespaceName, - displayName, - user - ); - - if (similarExtensions.isEmpty()) { - return; - } - - var similarExt = similarExtensions.get(0); - var latestVersion = repositories.findLatestVersion(similarExt, null, false, true); - String similarDisplayName = latestVersion != null ? latestVersion.getDisplayName() : null; - - throw new ErrorResultException(String.format( - "Extension '%s.%s' (display name: '%s') is too similar to existing extension '%s.%s' (display name: '%s'). " + - "Please choose a more distinct name to avoid confusion. " + - "Refer to the publishing guidelines: https://github.com/EclipseFdn/open-vsx.org/wiki/Publishing-Extensions", - namespaceName, - extensionName, - displayName, - similarExt.getNamespace().getName(), - similarExt.getName(), - similarDisplayName != null ? similarDisplayName : "" - )); } private void validateMetadata(ExtensionVersion extVersion) { @@ -247,8 +210,23 @@ private ExtensionId parseExtensionId(String extensionIdText, String formatType) } @Async - @Retryable public void publishAsync(TempFile extensionFile, ExtensionService extensionService) { + doPublish(extensionFile, extensionService); + } + + @Async + public void publishAsync(TempFile extensionFile, ExtensionService extensionService, ExtensionScan scan) { + try { + doPublish(extensionFile, extensionService); + scanService.completeScanningSuccess(scan); + } catch (Exception e) { + scanService.markScanAsErrored(scan, "Async processing failed: " + e.getMessage()); + throw e; + } + } + + @Retryable + private void doPublish(TempFile extensionFile, ExtensionService extensionService) { var download = extensionFile.getResource(); var extVersion = download.getExtension(); diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/AdminScanDecisionRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/AdminScanDecisionRepository.java new file mode 100644 index 000000000..f07ef968a --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/repositories/AdminScanDecisionRepository.java @@ -0,0 +1,124 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.repositories; + +import org.eclipse.openvsx.entities.AdminScanDecision; +import org.eclipse.openvsx.entities.ExtensionScan; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; +import org.springframework.data.util.Streamable; + +import javax.annotation.Nullable; + +import java.time.LocalDateTime; +import java.util.Collection; + +/** + * Repository for admin decisions on quarantined scans. + * Provides methods to query and persist allow/block decisions. + */ +public interface AdminScanDecisionRepository extends Repository { + + /** Save a new or update an existing decision */ + AdminScanDecision save(AdminScanDecision decision); + + /** Find a decision by its ID */ + AdminScanDecision findById(long id); + + /** Find the decision for a specific scan */ + AdminScanDecision findByScan(ExtensionScan scan); + + /** Find the decision for a scan by scan ID (eagerly fetches the admin user) */ + @Query("SELECT d FROM AdminScanDecision d JOIN FETCH d.decidedBy WHERE d.scan.id = :scanId") + AdminScanDecision findByScanId(long scanId); + + /** Check if a decision exists for a scan */ + boolean existsByScan(ExtensionScan scan); + + /** Check if a decision exists for a scan by ID */ + @Query("SELECT CASE WHEN COUNT(d) > 0 THEN true ELSE false END FROM AdminScanDecision d WHERE d.scan.id = :scanId") + boolean existsByScanId(long scanId); + + /** Find all decisions with a specific decision value */ + Streamable findByDecision(String decision); + + /** Count all ALLOWED decisions */ + long countByDecision(String decision); + + /** Count ALLOWED decisions within a date range. + * Uses native query to handle nullable timestamp parameters correctly with PostgreSQL. */ + @Query(value = """ + SELECT COUNT(*) FROM admin_scan_decision + WHERE decision = :decision + AND (CAST(:startedFrom AS TIMESTAMP) IS NULL OR decided_at >= :startedFrom) + AND (CAST(:startedTo AS TIMESTAMP) IS NULL OR decided_at <= :startedTo) + """, nativeQuery = true) + long countByDecisionAndDateRange(String decision, LocalDateTime startedFrom, LocalDateTime startedTo); + + /** + * Count decisions filtered by enforcement status of the associated scan's validation failures/threats. + * Used when enforcement filter is applied to scan counts. + */ + @Query(value = """ + SELECT COUNT(*) FROM admin_scan_decision d + WHERE d.decision = :decision + AND (EXISTS (SELECT 1 FROM extension_validation_failure f WHERE f.scan_id = d.scan_id AND f.enforced = :enforcedOnly) + OR EXISTS (SELECT 1 FROM extension_threat t WHERE t.scan_id = d.scan_id AND t.enforced = :enforcedOnly)) + """, nativeQuery = true) + long countByDecisionAndEnforcement(String decision, boolean enforcedOnly); + + /** + * Counts decisions where the associated scan has matching validation failures or threats. + * Date filtering is based on the scan's started_at timestamp (when the scan began). + */ + @Query(value = """ + SELECT COUNT(*) FROM admin_scan_decision d + JOIN extension_scan s ON s.id = d.scan_id + WHERE d.decision = :decision + AND (CAST(:startedFrom AS TIMESTAMP) IS NULL OR s.started_at >= :startedFrom) + AND (CAST(:startedTo AS TIMESTAMP) IS NULL OR s.started_at <= :startedTo) + AND (:applyCheckTypesFilter = false OR EXISTS ( + SELECT 1 FROM extension_validation_failure f + WHERE f.scan_id = d.scan_id + AND f.validation_type IN (:checkTypes) + AND (CAST(:enforcedOnly AS BOOLEAN) IS NULL OR f.enforced = :enforcedOnly))) + AND (:applyScannerNamesFilter = false OR EXISTS ( + SELECT 1 FROM extension_threat t + WHERE t.scan_id = d.scan_id + AND t.scanner_type IN (:scannerNames) + AND (CAST(:enforcedOnly AS BOOLEAN) IS NULL OR t.enforced = :enforcedOnly))) + AND (CAST(:enforcedOnly AS BOOLEAN) IS NULL + OR :applyCheckTypesFilter = true + OR :applyScannerNamesFilter = true + OR EXISTS (SELECT 1 FROM extension_validation_failure f WHERE f.scan_id = d.scan_id AND f.enforced = :enforcedOnly) + OR EXISTS (SELECT 1 FROM extension_threat t WHERE t.scan_id = d.scan_id AND t.enforced = :enforcedOnly)) + """, nativeQuery = true) + long countForStatistics( + @Param("decision") String decision, + @Nullable @Param("startedFrom") LocalDateTime startedFrom, + @Nullable @Param("startedTo") LocalDateTime startedTo, + @Param("checkTypes") Collection checkTypes, + @Param("applyCheckTypesFilter") boolean applyCheckTypesFilter, + @Param("scannerNames") Collection scannerNames, + @Param("applyScannerNamesFilter") boolean applyScannerNamesFilter, + @Nullable @Param("enforcedOnly") Boolean enforcedOnly + ); + + /** Delete a decision by ID */ + void deleteById(long id); + + /** Delete the decision for a scan */ + void deleteByScan(ExtensionScan scan); +} + diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionScanRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionScanRepository.java new file mode 100644 index 000000000..fc691be2d --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionScanRepository.java @@ -0,0 +1,317 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.repositories; + +import org.eclipse.openvsx.entities.ExtensionScan; +import org.eclipse.openvsx.entities.ScanStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; +import org.springframework.data.util.Streamable; + +import javax.annotation.Nullable; + +import java.time.LocalDateTime; +import java.util.Collection; + +/** + * Repository for accessing ExtensionScan entities. + */ +public interface ExtensionScanRepository extends Repository { + + /** Save a new or update an existing scan */ + ExtensionScan save(ExtensionScan scan); + + /** Find a scan by its ID */ + ExtensionScan findById(long id); + + /** Find all scans for a specific extension version */ + Streamable findByNamespaceNameAndExtensionNameAndExtensionVersionAndTargetPlatform( + String namespaceName, String extensionName, String extensionVersion, String targetPlatform); + + /** Find the most recent scan for a specific extension version */ + ExtensionScan findFirstByNamespaceNameAndExtensionNameAndExtensionVersionAndTargetPlatformOrderByStartedAtDesc( + String namespaceName, String extensionName, String extensionVersion, String targetPlatform); + + /** Find all scans for a specific extension */ + Streamable findByNamespaceNameAndExtensionName(String namespaceName, String extensionName); + + /** Find all scans for a namespace */ + Streamable findByNamespaceName(String namespaceName); + + /** Find all scans with a specific status */ + Streamable findByStatus(ScanStatus status); + + /** Find all scans that are still in progress */ + Streamable findByCompletedAtIsNull(); + + /** Find all scans started after a specific date */ + Streamable findByStartedAtAfter(LocalDateTime date); + + /** Find all scans with specific status started after a date */ + Streamable findByStatusAndStartedAtAfter(ScanStatus status, LocalDateTime date); + + /** Count all scans with a specific status */ + long countByStatus(ScanStatus status); + + /** Count scans for a specific extension */ + long countByNamespaceNameAndExtensionName(String namespaceName, String extensionName); + + /** Delete a scan by ID */ + void deleteById(long id); + + /** Find all scans by status, ordered by start date */ + Streamable findByStatusOrderByStartedAtDesc(ScanStatus status); + + /** Check if a scan exists for a specific version with a given status */ + boolean existsByNamespaceNameAndExtensionNameAndExtensionVersionAndTargetPlatformAndStatus( + String namespaceName, String extensionName, String extensionVersion, String targetPlatform, ScanStatus status); + + /** Find all scans ordered by start date */ + Streamable findAllByOrderByStartedAtDesc(); + + /** + * Paginated query with optional filters for status, namespace, publisher, name, and date range. + * + * Filter parameters: + * - statuses: list of ScanStatus values (empty = no filter) + * - namespace: partial match on namespace_name (null/empty = no filter) + * - publisher: partial match on publisher (null/empty = no filter) + * - name: partial match on extension_name OR extension_display_name (null/empty = no filter) + * - startedFrom/startedTo: date range filter on started_at (null = no filter) + */ + @Query(value = """ + SELECT s.* FROM extension_scan s + WHERE (CAST(:statuses AS TEXT) IS NULL OR s.status IN (:statuses)) + AND (CAST(:namespace AS TEXT) IS NULL OR LOWER(s.namespace_name) LIKE LOWER('%' || :namespace || '%')) + AND (CAST(:publisher AS TEXT) IS NULL OR LOWER(s.publisher) LIKE LOWER('%' || :publisher || '%')) + AND (CAST(:name AS TEXT) IS NULL OR LOWER(s.extension_name) LIKE LOWER('%' || :name || '%') + OR LOWER(s.extension_display_name) LIKE LOWER('%' || :name || '%')) + AND (CAST(:startedFrom AS TIMESTAMP) IS NULL OR s.started_at >= :startedFrom) + AND (CAST(:startedTo AS TIMESTAMP) IS NULL OR s.started_at <= :startedTo) + """, nativeQuery = true) + Page findScansFiltered( + @Param("statuses") Collection statuses, + @Param("namespace") String namespace, + @Param("publisher") String publisher, + @Param("name") String name, + @Param("startedFrom") LocalDateTime startedFrom, + @Param("startedTo") LocalDateTime startedTo, + Pageable pageable + ); + + /** + * Count scans matching filters (for totalSize without loading all records). + */ + @Query(value = """ + SELECT COUNT(*) FROM extension_scan s + WHERE (CAST(:statuses AS TEXT) IS NULL OR s.status IN (:statuses)) + AND (CAST(:namespace AS TEXT) IS NULL OR LOWER(s.namespace_name) LIKE LOWER('%' || :namespace || '%')) + AND (CAST(:publisher AS TEXT) IS NULL OR LOWER(s.publisher) LIKE LOWER('%' || :publisher || '%')) + AND (CAST(:name AS TEXT) IS NULL OR LOWER(s.extension_name) LIKE LOWER('%' || :name || '%') + OR LOWER(s.extension_display_name) LIKE LOWER('%' || :name || '%')) + AND (CAST(:startedFrom AS TIMESTAMP) IS NULL OR s.started_at >= :startedFrom) + AND (CAST(:startedTo AS TIMESTAMP) IS NULL OR s.started_at <= :startedTo) + """, nativeQuery = true) + long countScansFiltered( + @Param("statuses") Collection statuses, + @Param("namespace") String namespace, + @Param("publisher") String publisher, + @Param("name") String name, + @Param("startedFrom") LocalDateTime startedFrom, + @Param("startedTo") LocalDateTime startedTo + ); + + /** + * Count scans by status with date range filter. + */ + @Query(value = """ + SELECT COUNT(*) FROM extension_scan + WHERE status = :#{#status.name()} + AND (CAST(:startedFrom AS TIMESTAMP) IS NULL OR started_at >= :startedFrom) + AND (CAST(:startedTo AS TIMESTAMP) IS NULL OR started_at <= :startedTo) + """, nativeQuery = true) + long countByStatusAndDateRange( + @Param("status") ScanStatus status, + @Param("startedFrom") LocalDateTime startedFrom, + @Param("startedTo") LocalDateTime startedTo + ); + + /** + * Count scans by status with date range AND enforcement filter. + */ + @Query(value = """ + SELECT COUNT(*) FROM extension_scan s + WHERE s.status = :#{#status.name()} + AND (CAST(:startedFrom AS TIMESTAMP) IS NULL OR s.started_at >= :startedFrom) + AND (CAST(:startedTo AS TIMESTAMP) IS NULL OR s.started_at <= :startedTo) + AND EXISTS ( + SELECT 1 FROM extension_validation_failure f + WHERE f.scan_id = s.id AND f.enforced = :enforcedOnly) + """, nativeQuery = true) + long countByStatusDateRangeAndEnforcement( + @Param("status") ScanStatus status, + @Param("startedFrom") LocalDateTime startedFrom, + @Param("startedTo") LocalDateTime startedTo, + @Param("enforcedOnly") boolean enforcedOnly + ); + + /** + * Count scans for statistics with all filters. + * + * Enforcement behavior matches the list endpoint: + * - When checkTypes is specified: enforcement modifies that filter (AND logic) + * - When scannerNames is specified: enforcement modifies that filter (AND logic) + * - When neither is specified: enforcement filters on ANY validation failure or threat + */ + @Query(value = """ + SELECT COUNT(*) FROM extension_scan s + WHERE s.status = :status + AND (CAST(:startedFrom AS TIMESTAMP) IS NULL OR s.started_at >= :startedFrom) + AND (CAST(:startedTo AS TIMESTAMP) IS NULL OR s.started_at <= :startedTo) + AND (:applyCheckTypesFilter = false OR EXISTS ( + SELECT 1 FROM extension_validation_failure f + WHERE f.scan_id = s.id + AND f.validation_type IN (:checkTypes) + AND (CAST(:enforcedOnly AS BOOLEAN) IS NULL OR f.enforced = :enforcedOnly))) + AND (:applyScannerNamesFilter = false OR EXISTS ( + SELECT 1 FROM extension_threat t + WHERE t.scan_id = s.id + AND t.scanner_type IN (:scannerNames) + AND (CAST(:enforcedOnly AS BOOLEAN) IS NULL OR t.enforced = :enforcedOnly))) + AND (CAST(:enforcedOnly AS BOOLEAN) IS NULL + OR :applyCheckTypesFilter = true + OR :applyScannerNamesFilter = true + OR EXISTS (SELECT 1 FROM extension_validation_failure f WHERE f.scan_id = s.id AND f.enforced = :enforcedOnly) + OR EXISTS (SELECT 1 FROM extension_threat t WHERE t.scan_id = s.id AND t.enforced = :enforcedOnly)) + """, nativeQuery = true) + long countForStatistics( + @Param("status") String status, + @Nullable @Param("startedFrom") LocalDateTime startedFrom, + @Nullable @Param("startedTo") LocalDateTime startedTo, + @Param("checkTypes") Collection checkTypes, + @Param("applyCheckTypesFilter") boolean applyCheckTypesFilter, + @Param("scannerNames") Collection scannerNames, + @Param("applyScannerNamesFilter") boolean applyScannerNamesFilter, + @Nullable @Param("enforcedOnly") Boolean enforcedOnly + ); + + /** + * Full paginated query with ALL filters including validationType, scannerNames, enforcement, and adminDecision. + * + * Enforcement behavior: + * - When validationType is specified: enforcement modifies that filter (AND logic) + * - When threatScannerName is specified: enforcement modifies that filter (AND logic) + * - When neither is specified: enforcement filters on ANY validation failure or threat + */ + @Query(value = """ + SELECT s.* FROM extension_scan s + WHERE (CAST(:statuses AS TEXT) IS NULL OR s.status IN (:statuses)) + AND (CAST(:namespace AS TEXT) IS NULL OR LOWER(s.namespace_name) LIKE LOWER('%' || :namespace || '%')) + AND (CAST(:publisher AS TEXT) IS NULL OR LOWER(s.publisher) LIKE LOWER('%' || :publisher || '%')) + AND (CAST(:name AS TEXT) IS NULL OR LOWER(s.extension_name) LIKE LOWER('%' || :name || '%') + OR LOWER(s.extension_display_name) LIKE LOWER('%' || :name || '%')) + AND (CAST(:startedFrom AS TIMESTAMP) IS NULL OR s.started_at >= :startedFrom) + AND (CAST(:startedTo AS TIMESTAMP) IS NULL OR s.started_at <= :startedTo) + AND (:applyCheckTypesFilter = false OR EXISTS ( + SELECT 1 FROM extension_validation_failure f + WHERE f.scan_id = s.id + AND f.validation_type IN (:checkTypes) + AND (CAST(:enforcedOnly AS BOOLEAN) IS NULL OR f.enforced = :enforcedOnly))) + AND (:applyScannerNamesFilter = false OR EXISTS ( + SELECT 1 FROM extension_threat t + WHERE t.scan_id = s.id + AND t.scanner_type IN (:scannerNames) + AND (CAST(:enforcedOnly AS BOOLEAN) IS NULL OR t.enforced = :enforcedOnly))) + AND (CAST(:enforcedOnly AS BOOLEAN) IS NULL + OR :applyCheckTypesFilter = true + OR :applyScannerNamesFilter = true + OR EXISTS (SELECT 1 FROM extension_validation_failure f WHERE f.scan_id = s.id AND f.enforced = :enforcedOnly) + OR EXISTS (SELECT 1 FROM extension_threat t WHERE t.scan_id = s.id AND t.enforced = :enforcedOnly)) + AND (:applyAdminDecisionFilter = false OR ( + (:filterAllowed = true AND EXISTS (SELECT 1 FROM admin_scan_decision d WHERE d.scan_id = s.id AND d.decision = 'ALLOWED')) + OR (:filterBlocked = true AND EXISTS (SELECT 1 FROM admin_scan_decision d WHERE d.scan_id = s.id AND d.decision = 'BLOCKED')) + OR (:filterNeedsReview = true AND s.status = 'QUARANTINED' AND NOT EXISTS (SELECT 1 FROM admin_scan_decision d WHERE d.scan_id = s.id)))) + """, nativeQuery = true) + Page findScansFullyFiltered( + @Nullable @Param("statuses") Collection statuses, + @Nullable @Param("namespace") String namespace, + @Nullable @Param("publisher") String publisher, + @Nullable @Param("name") String name, + @Nullable @Param("startedFrom") LocalDateTime startedFrom, + @Nullable @Param("startedTo") LocalDateTime startedTo, + @Param("checkTypes") Collection checkTypes, + @Param("applyCheckTypesFilter") boolean applyCheckTypesFilter, + @Param("scannerNames") Collection scannerNames, + @Param("applyScannerNamesFilter") boolean applyScannerNamesFilter, + @Nullable @Param("enforcedOnly") Boolean enforcedOnly, + @Param("applyAdminDecisionFilter") boolean applyAdminDecisionFilter, + @Param("filterAllowed") boolean filterAllowed, + @Param("filterBlocked") boolean filterBlocked, + @Param("filterNeedsReview") boolean filterNeedsReview, + Pageable pageable + ); + + /** + * Count version of findScansFullyFiltered. + */ + @Query(value = """ + SELECT COUNT(*) FROM extension_scan s + WHERE (CAST(:statuses AS TEXT) IS NULL OR s.status IN (:statuses)) + AND (CAST(:namespace AS TEXT) IS NULL OR LOWER(s.namespace_name) LIKE LOWER('%' || :namespace || '%')) + AND (CAST(:publisher AS TEXT) IS NULL OR LOWER(s.publisher) LIKE LOWER('%' || :publisher || '%')) + AND (CAST(:name AS TEXT) IS NULL OR LOWER(s.extension_name) LIKE LOWER('%' || :name || '%') + OR LOWER(s.extension_display_name) LIKE LOWER('%' || :name || '%')) + AND (CAST(:startedFrom AS TIMESTAMP) IS NULL OR s.started_at >= :startedFrom) + AND (CAST(:startedTo AS TIMESTAMP) IS NULL OR s.started_at <= :startedTo) + AND (:applyCheckTypesFilter = false OR EXISTS ( + SELECT 1 FROM extension_validation_failure f + WHERE f.scan_id = s.id + AND f.validation_type IN (:checkTypes) + AND (CAST(:enforcedOnly AS BOOLEAN) IS NULL OR f.enforced = :enforcedOnly))) + AND (:applyScannerNamesFilter = false OR EXISTS ( + SELECT 1 FROM extension_threat t + WHERE t.scan_id = s.id + AND t.scanner_type IN (:scannerNames) + AND (CAST(:enforcedOnly AS BOOLEAN) IS NULL OR t.enforced = :enforcedOnly))) + AND (CAST(:enforcedOnly AS BOOLEAN) IS NULL + OR :applyCheckTypesFilter = true + OR :applyScannerNamesFilter = true + OR EXISTS (SELECT 1 FROM extension_validation_failure f WHERE f.scan_id = s.id AND f.enforced = :enforcedOnly) + OR EXISTS (SELECT 1 FROM extension_threat t WHERE t.scan_id = s.id AND t.enforced = :enforcedOnly)) + AND (:applyAdminDecisionFilter = false OR ( + (:filterAllowed = true AND EXISTS (SELECT 1 FROM admin_scan_decision d WHERE d.scan_id = s.id AND d.decision = 'ALLOWED')) + OR (:filterBlocked = true AND EXISTS (SELECT 1 FROM admin_scan_decision d WHERE d.scan_id = s.id AND d.decision = 'BLOCKED')) + OR (:filterNeedsReview = true AND s.status = 'QUARANTINED' AND NOT EXISTS (SELECT 1 FROM admin_scan_decision d WHERE d.scan_id = s.id)))) + """, nativeQuery = true) + long countScansFullyFiltered( + @Nullable @Param("statuses") Collection statuses, + @Nullable @Param("namespace") String namespace, + @Nullable @Param("publisher") String publisher, + @Nullable @Param("name") String name, + @Nullable @Param("startedFrom") LocalDateTime startedFrom, + @Nullable @Param("startedTo") LocalDateTime startedTo, + @Param("checkTypes") Collection checkTypes, + @Param("applyCheckTypesFilter") boolean applyCheckTypesFilter, + @Param("scannerNames") Collection scannerNames, + @Param("applyScannerNamesFilter") boolean applyScannerNamesFilter, + @Nullable @Param("enforcedOnly") Boolean enforcedOnly, + @Param("applyAdminDecisionFilter") boolean applyAdminDecisionFilter, + @Param("filterAllowed") boolean filterAllowed, + @Param("filterBlocked") boolean filterBlocked, + @Param("filterNeedsReview") boolean filterNeedsReview + ); +} + diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionThreatRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionThreatRepository.java new file mode 100644 index 000000000..16e7efe82 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionThreatRepository.java @@ -0,0 +1,83 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.repositories; + +import org.eclipse.openvsx.entities.ExtensionScan; +import org.eclipse.openvsx.entities.ExtensionThreat; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.util.Streamable; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * Repository for extension threat detection results. + */ +public interface ExtensionThreatRepository extends Repository { + + /** Save a new or update an existing threat */ + ExtensionThreat save(ExtensionThreat threat); + + /** Find a threat by its ID */ + ExtensionThreat findById(long id); + + /** Find all threats for a specific scan */ + Streamable findByScan(ExtensionScan scan); + + /** Find all threats for a scan by scan ID */ + @Query("SELECT t FROM ExtensionThreat t WHERE t.scan.id = :scanId") + Streamable findByScanId(long scanId); + + /** Find all threats detected by a specific scanner type */ + Streamable findByType(String type); + + /** Find all threats with a specific file hash */ + Streamable findByFileHash(String fileHash); + + /** Count all threats for a scan */ + long countByScan(ExtensionScan scan); + + /** Check if any threats exist for a scan */ + boolean existsByScan(ExtensionScan scan); + + /** Check if threats from a specific scanner type exist for a scan */ + boolean existsByScanAndType(ExtensionScan scan, String type); + + /** Find all threats for a scan with a specific scanner type */ + Streamable findByScanAndType(ExtensionScan scan, String type); + + /** Find all threats detected after a specific date */ + Streamable findByDetectedAtAfter(LocalDateTime date); + + /** Count all threats by scanner type */ + long countByType(String type); + + /** Find all threats for a scan, ordered by detection time */ + Streamable findByScanOrderByDetectedAtAsc(ExtensionScan scan); + + /** Find distinct scanner types from all threats (for filter options) */ + @Query("SELECT DISTINCT t.type FROM ExtensionThreat t ORDER BY t.type") + List findDistinctScannerTypes(); + + /** Find distinct rule names from all threats (for filter options) */ + @Query("SELECT DISTINCT t.ruleName FROM ExtensionThreat t ORDER BY t.ruleName") + List findDistinctRuleNames(); + + /** Delete a threat by ID */ + void deleteById(long id); + + /** Delete all threats for a scan */ + void deleteByScan(ExtensionScan scan); +} + diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionValidationFailureRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionValidationFailureRepository.java new file mode 100644 index 000000000..add1d6da5 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionValidationFailureRepository.java @@ -0,0 +1,80 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.repositories; + +import org.eclipse.openvsx.entities.ExtensionScan; +import org.eclipse.openvsx.entities.ExtensionValidationFailure; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.util.Streamable; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * Repository for accessing ExtensionValidationFailure entities. + */ +public interface ExtensionValidationFailureRepository extends Repository { + + /** Save a new or update an existing validation failure */ + ExtensionValidationFailure save(ExtensionValidationFailure failure); + + /** Find a validation failure by its ID */ + ExtensionValidationFailure findById(long id); + + /** Find all validation failures for a specific scan */ + Streamable findByScan(ExtensionScan scan); + + /** Find all validation failures of a specific check type */ + Streamable findByCheckType(String checkType); + + /** Find all validation failures for a scan with a specific check type */ + Streamable findByScanAndCheckType(ExtensionScan scan, String checkType); + + /** Find all validation failures detected after a specific date */ + Streamable findByDetectedAtAfter(LocalDateTime date); + + /** Count all validation failures for a specific scan */ + long countByScan(ExtensionScan scan); + + /** Count all validation failures of a specific check type */ + long countByCheckType(String checkType); + + /** Check if any validation failures exist for a scan */ + boolean existsByScan(ExtensionScan scan); + + /** Check if validation failures of a specific type exist for a scan */ + boolean existsByScanAndCheckType(ExtensionScan scan, String checkType); + + /** Delete all validation failures for a scan */ + void deleteByScan(ExtensionScan scan); + + /** Delete a validation failure by ID */ + void deleteById(long id); + + /** Find all validation failures for a scan, ordered by detection time */ + Streamable findByScanOrderByDetectedAtAsc(ExtensionScan scan); + + /** + * Returns a sorted list of distinct rule names. + */ + @Query("select distinct f.ruleName from ExtensionValidationFailure f order by f.ruleName") + List findDistinctRuleNames(); + + /** + * Returns a sorted list of distinct check types. + */ + @Query("select distinct f.checkType from ExtensionValidationFailure f order by f.checkType") + List findDistinctCheckTypes(); +} + diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/FileDecisionRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/FileDecisionRepository.java new file mode 100644 index 000000000..62cad46f6 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/repositories/FileDecisionRepository.java @@ -0,0 +1,118 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.repositories; + +import org.eclipse.openvsx.entities.FileDecision; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; +import org.springframework.data.util.Streamable; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * Repository for file allow/block list decisions. + */ +public interface FileDecisionRepository extends Repository { + + /** Save a new or update an existing file decision */ + FileDecision save(FileDecision decision); + + /** Find a decision by its ID (eagerly fetches the admin user) */ + @Query("SELECT f FROM FileDecision f JOIN FETCH f.decidedBy WHERE f.id = :id") + FileDecision findById(@Param("id") long id); + + /** Find a decision by file hash (eagerly fetches the admin user) */ + @Query("SELECT f FROM FileDecision f JOIN FETCH f.decidedBy WHERE f.fileHash = :fileHash") + FileDecision findByFileHash(@Param("fileHash") String fileHash); + + /** Find multiple decisions by IDs (eagerly fetches admin users in a single query) */ + @Query("SELECT f FROM FileDecision f JOIN FETCH f.decidedBy WHERE f.id IN :ids") + List findByIdIn(@Param("ids") List ids); + + /** Check if a decision exists for a file hash */ + boolean existsByFileHash(String fileHash); + + /** Find all decisions with a specific decision value */ + Streamable findByDecision(String decision); + + /** Count all decisions with a specific decision value */ + long countByDecision(String decision); + + /** Count total file decisions */ + long count(); + + /** Delete a decision by ID */ + void deleteById(long id); + + /** Delete a decision by file hash */ + void deleteByFileHash(String fileHash); + + /** + * Paginated query with optional filters for decision, publisher, namespace, name, and date range. + */ + @Query(value = """ + SELECT f.*, u.id AS user_id, u.login_name, u.email, u.full_name, u.avatar_url, + u.provider, u.provider_url, u.auth_id, u.role, u.eclipse_token, u.eclipse_person_id + FROM file_decision f + JOIN user_data u ON u.id = f.decided_by_id + WHERE (CAST(:decision AS TEXT) IS NULL OR f.decision = :decision) + AND (CAST(:publisher AS TEXT) IS NULL OR LOWER(f.publisher) LIKE LOWER('%' || :publisher || '%')) + AND (CAST(:namespace AS TEXT) IS NULL OR LOWER(f.namespace_name) LIKE LOWER('%' || :namespace || '%')) + AND (CAST(:name AS TEXT) IS NULL OR LOWER(f.extension_name) LIKE LOWER('%' || :name || '%') + OR LOWER(f.display_name) LIKE LOWER('%' || :name || '%') + OR LOWER(f.file_name) LIKE LOWER('%' || :name || '%')) + AND (CAST(:decidedFrom AS TIMESTAMP) IS NULL OR f.decided_at >= :decidedFrom) + AND (CAST(:decidedTo AS TIMESTAMP) IS NULL OR f.decided_at <= :decidedTo) + """, + countQuery = """ + SELECT COUNT(*) FROM file_decision f + WHERE (CAST(:decision AS TEXT) IS NULL OR f.decision = :decision) + AND (CAST(:publisher AS TEXT) IS NULL OR LOWER(f.publisher) LIKE LOWER('%' || :publisher || '%')) + AND (CAST(:namespace AS TEXT) IS NULL OR LOWER(f.namespace_name) LIKE LOWER('%' || :namespace || '%')) + AND (CAST(:name AS TEXT) IS NULL OR LOWER(f.extension_name) LIKE LOWER('%' || :name || '%') + OR LOWER(f.display_name) LIKE LOWER('%' || :name || '%') + OR LOWER(f.file_name) LIKE LOWER('%' || :name || '%')) + AND (CAST(:decidedFrom AS TIMESTAMP) IS NULL OR f.decided_at >= :decidedFrom) + AND (CAST(:decidedTo AS TIMESTAMP) IS NULL OR f.decided_at <= :decidedTo) + """, + nativeQuery = true) + Page findFilesFiltered( + @Param("decision") String decision, + @Param("publisher") String publisher, + @Param("namespace") String namespace, + @Param("name") String name, + @Param("decidedFrom") LocalDateTime decidedFrom, + @Param("decidedTo") LocalDateTime decidedTo, + Pageable pageable + ); + + /** + * Count file decisions within a date range. + */ + @Query(value = """ + SELECT COUNT(*) FROM file_decision + WHERE decision = :decision + AND (CAST(:decidedFrom AS TIMESTAMP) IS NULL OR decided_at >= :decidedFrom) + AND (CAST(:decidedTo AS TIMESTAMP) IS NULL OR decided_at <= :decidedTo) + """, nativeQuery = true) + long countByDecisionAndDateRange( + @Param("decision") String decision, + @Param("decidedFrom") LocalDateTime decidedFrom, + @Param("decidedTo") LocalDateTime decidedTo + ); +} + diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java index 2952e3392..f0130f5d4 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -20,6 +20,8 @@ import org.springframework.data.util.Streamable; import org.springframework.stereotype.Component; +import javax.annotation.Nullable; + import java.time.LocalDateTime; import java.util.Collection; import java.util.List; @@ -60,6 +62,11 @@ public class RepositoryService { private final MigrationItemJooqRepository migrationItemJooqRepo; private final SignatureKeyPairRepository signatureKeyPairRepo; private final SignatureKeyPairJooqRepository signatureKeyPairJooqRepo; + private final ExtensionScanRepository extensionScanRepo; + private final ExtensionValidationFailureRepository extensionValidationFailureRepo; + private final AdminScanDecisionRepository adminScanDecisionRepo; + private final ExtensionThreatRepository extensionThreatRepo; + private final FileDecisionRepository fileDecisionRepo; public RepositoryService( NamespaceRepository namespaceRepo, @@ -84,7 +91,12 @@ public RepositoryService( MigrationItemRepository migrationItemRepo, MigrationItemJooqRepository migrationItemJooqRepo, SignatureKeyPairRepository signatureKeyPairRepo, - SignatureKeyPairJooqRepository signatureKeyPairJooqRepo + SignatureKeyPairJooqRepository signatureKeyPairJooqRepo, + ExtensionScanRepository extensionScanRepo, + AdminScanDecisionRepository adminScanDecisionRepo, + ExtensionValidationFailureRepository extensionValidationFailureRepo, + ExtensionThreatRepository extensionThreatRepo, + FileDecisionRepository fileDecisionRepo ) { this.namespaceRepo = namespaceRepo; this.namespaceJooqRepo = namespaceJooqRepo; @@ -109,6 +121,11 @@ public RepositoryService( this.migrationItemJooqRepo = migrationItemJooqRepo; this.signatureKeyPairRepo = signatureKeyPairRepo; this.signatureKeyPairJooqRepo = signatureKeyPairJooqRepo; + this.extensionScanRepo = extensionScanRepo; + this.adminScanDecisionRepo = adminScanDecisionRepo; + this.extensionValidationFailureRepo = extensionValidationFailureRepo; + this.extensionThreatRepo = extensionThreatRepo; + this.fileDecisionRepo = fileDecisionRepo; } public Namespace findNamespace(String name) { @@ -695,4 +712,446 @@ public List findSimilarNamespacesByLevenshtein( limit ); } + + public ExtensionScan saveExtensionScan(ExtensionScan scan) { + return extensionScanRepo.save(scan); + } + + public void deleteExtensionScan(ExtensionScan scan) { + extensionScanRepo.deleteById(scan.getId()); + } + + public ExtensionScan findExtensionScan(long id) { + return extensionScanRepo.findById(id); + } + + public Streamable findExtensionScans(ExtensionVersion version) { + var extension = version.getExtension(); + var namespace = extension.getNamespace(); + return extensionScanRepo.findByNamespaceNameAndExtensionNameAndExtensionVersionAndTargetPlatform( + namespace.getName(), extension.getName(), version.getVersion(), version.getTargetPlatform()); + } + + public ExtensionScan findLatestExtensionScan(ExtensionVersion version) { + var extension = version.getExtension(); + var namespace = extension.getNamespace(); + return extensionScanRepo.findFirstByNamespaceNameAndExtensionNameAndExtensionVersionAndTargetPlatformOrderByStartedAtDesc( + namespace.getName(), extension.getName(), version.getVersion(), version.getTargetPlatform()); + } + + public Streamable findExtensionScans(Extension extension) { + var namespace = extension.getNamespace(); + return extensionScanRepo.findByNamespaceNameAndExtensionName(namespace.getName(), extension.getName()); + } + + public Streamable findExtensionScansByNamespace(String namespaceName) { + return extensionScanRepo.findByNamespaceName(namespaceName); + } + + public Streamable findExtensionScansByStatus(ScanStatus status) { + return extensionScanRepo.findByStatus(status); + } + + public Streamable findInProgressExtensionScans() { + return extensionScanRepo.findByCompletedAtIsNull(); + } + + public long countExtensionScansByStatus(ScanStatus status) { + return extensionScanRepo.countByStatus(status); + } + + /** Check if a scan exists for a specific version with a given status */ + public boolean hasExtensionScanWithStatus(ExtensionVersion version, ScanStatus status) { + var extension = version.getExtension(); + var namespace = extension.getNamespace(); + return extensionScanRepo.existsByNamespaceNameAndExtensionNameAndExtensionVersionAndTargetPlatformAndStatus( + namespace.getName(), extension.getName(), version.getVersion(), version.getTargetPlatform(), status); + } + + public Streamable findAllExtensionScans() { + return extensionScanRepo.findAllByOrderByStartedAtDesc(); + } + + public org.springframework.data.domain.Page findScansFiltered( + Collection statuses, + String namespace, + String publisher, + String name, + LocalDateTime startedFrom, + LocalDateTime startedTo, + org.springframework.data.domain.Pageable pageable + ) { + // Convert empty collections to null, and enums to strings for native query + var statusesParam = (statuses == null || statuses.isEmpty()) + ? null + : statuses.stream().map(ScanStatus::name).toList(); + var namespaceParam = (namespace == null || namespace.isBlank()) ? null : namespace; + var publisherParam = (publisher == null || publisher.isBlank()) ? null : publisher; + var nameParam = (name == null || name.isBlank()) ? null : name; + + return extensionScanRepo.findScansFiltered( + statusesParam, namespaceParam, publisherParam, nameParam, startedFrom, startedTo, pageable + ); + } + + public long countScansFiltered( + Collection statuses, + String namespace, + String publisher, + String name, + LocalDateTime startedFrom, + LocalDateTime startedTo + ) { + // Convert enums to strings for native query + var statusesParam = (statuses == null || statuses.isEmpty()) + ? null + : statuses.stream().map(ScanStatus::name).toList(); + var namespaceParam = (namespace == null || namespace.isBlank()) ? null : namespace; + var publisherParam = (publisher == null || publisher.isBlank()) ? null : publisher; + var nameParam = (name == null || name.isBlank()) ? null : name; + + return extensionScanRepo.countScansFiltered( + statusesParam, namespaceParam, publisherParam, nameParam, startedFrom, startedTo + ); + } + + public long countExtensionScansByStatusAndDateRange(ScanStatus status, LocalDateTime startedFrom, LocalDateTime startedTo) { + return extensionScanRepo.countByStatusAndDateRange(status, startedFrom, startedTo); + } + + public long countExtensionScansByStatusDateRangeAndEnforcement( + ScanStatus status, LocalDateTime startedFrom, LocalDateTime startedTo, boolean enforcedOnly) { + return extensionScanRepo.countByStatusDateRangeAndEnforcement(status, startedFrom, startedTo, enforcedOnly); + } + + public org.springframework.data.domain.Page findScansFullyFiltered( + @Nullable Collection statuses, + @Nullable String namespace, + @Nullable String publisher, + @Nullable String name, + @Nullable LocalDateTime startedFrom, + @Nullable LocalDateTime startedTo, + @Nullable Collection checkTypes, + @Nullable Collection scannerNames, + @Nullable Boolean enforcedOnly, + @Nullable org.eclipse.openvsx.admin.ScanAPI.AdminDecisionFilterValues adminDecisionFilter, + org.springframework.data.domain.Pageable pageable + ) { + // Convert enums to strings for native query + var statusesParam = (statuses == null || statuses.isEmpty()) + ? null + : statuses.stream().map(ScanStatus::name).toList(); + var namespaceParam = (namespace == null || namespace.isBlank()) ? null : namespace; + var publisherParam = (publisher == null || publisher.isBlank()) ? null : publisher; + var nameParam = (name == null || name.isBlank()) ? null : name; + // PostgreSQL doesn't allow empty IN clauses. When filter is disabled, we pass a + // dummy list combined with a boolean flag in the query to skip the check entirely. + var applyCheckTypesFilter = checkTypes != null && !checkTypes.isEmpty(); + var applyScannerNamesFilter = scannerNames != null && !scannerNames.isEmpty(); + var checkTypesParam = applyCheckTypesFilter ? checkTypes : List.of(""); + var scannerNamesParam = applyScannerNamesFilter ? scannerNames : List.of(""); + + // Admin decision filter + var applyAdminDecisionFilter = adminDecisionFilter != null && adminDecisionFilter.hasFilter(); + var filterAllowed = adminDecisionFilter != null && adminDecisionFilter.filterAllowed(); + var filterBlocked = adminDecisionFilter != null && adminDecisionFilter.filterBlocked(); + var filterNeedsReview = adminDecisionFilter != null && adminDecisionFilter.filterNeedsReview(); + + return extensionScanRepo.findScansFullyFiltered( + statusesParam, namespaceParam, publisherParam, nameParam, + startedFrom, startedTo, checkTypesParam, applyCheckTypesFilter, + scannerNamesParam, applyScannerNamesFilter, enforcedOnly, + applyAdminDecisionFilter, filterAllowed, filterBlocked, filterNeedsReview, + pageable + ); + } + + public long countScansFullyFiltered( + @Nullable Collection statuses, + @Nullable String namespace, + @Nullable String publisher, + @Nullable String name, + @Nullable LocalDateTime startedFrom, + @Nullable LocalDateTime startedTo, + @Nullable Collection checkTypes, + @Nullable Collection scannerNames, + @Nullable Boolean enforcedOnly, + @Nullable org.eclipse.openvsx.admin.ScanAPI.AdminDecisionFilterValues adminDecisionFilter + ) { + // Convert enums to strings for native query + var statusesParam = (statuses == null || statuses.isEmpty()) + ? null + : statuses.stream().map(ScanStatus::name).toList(); + var namespaceParam = (namespace == null || namespace.isBlank()) ? null : namespace; + var publisherParam = (publisher == null || publisher.isBlank()) ? null : publisher; + var nameParam = (name == null || name.isBlank()) ? null : name; + // PostgreSQL doesn't allow empty IN clauses. When filter is disabled, we pass a + // dummy list combined with a boolean flag in the query to skip the check entirely. + var applyCheckTypesFilter = checkTypes != null && !checkTypes.isEmpty(); + var applyScannerNamesFilter = scannerNames != null && !scannerNames.isEmpty(); + var checkTypesParam = applyCheckTypesFilter ? checkTypes : List.of(""); + var scannerNamesParam = applyScannerNamesFilter ? scannerNames : List.of(""); + + // Admin decision filter + var applyAdminDecisionFilter = adminDecisionFilter != null && adminDecisionFilter.hasFilter(); + var filterAllowed = adminDecisionFilter != null && adminDecisionFilter.filterAllowed(); + var filterBlocked = adminDecisionFilter != null && adminDecisionFilter.filterBlocked(); + var filterNeedsReview = adminDecisionFilter != null && adminDecisionFilter.filterNeedsReview(); + + return extensionScanRepo.countScansFullyFiltered( + statusesParam, namespaceParam, publisherParam, nameParam, + startedFrom, startedTo, checkTypesParam, applyCheckTypesFilter, + scannerNamesParam, applyScannerNamesFilter, enforcedOnly, + applyAdminDecisionFilter, filterAllowed, filterBlocked, filterNeedsReview + ); + } + + public long countScansForStatistics( + ScanStatus status, + @Nullable LocalDateTime startedFrom, + @Nullable LocalDateTime startedTo, + @Nullable Collection checkTypes, + @Nullable Collection scannerNames, + @Nullable Boolean enforcedOnly + ) { + // PostgreSQL doesn't allow empty IN clauses. When filter is disabled, we pass a + // dummy list combined with a boolean flag in the query to skip the check entirely. + var applyCheckTypesFilter = checkTypes != null && !checkTypes.isEmpty(); + var applyScannerNamesFilter = scannerNames != null && !scannerNames.isEmpty(); + var checkTypesParam = applyCheckTypesFilter ? checkTypes : List.of(""); + var scannerNamesParam = applyScannerNamesFilter ? scannerNames : List.of(""); + + return extensionScanRepo.countForStatistics( + status.name(), startedFrom, startedTo, + checkTypesParam, applyCheckTypesFilter, + scannerNamesParam, applyScannerNamesFilter, enforcedOnly + ); + } + + public long countAdminDecisionsForStatistics( + String decision, + @Nullable LocalDateTime startedFrom, + @Nullable LocalDateTime startedTo, + @Nullable Collection checkTypes, + @Nullable Collection scannerNames, + @Nullable Boolean enforcedOnly + ) { + // PostgreSQL doesn't allow empty IN clauses. When filter is disabled, we pass a + // dummy list combined with a boolean flag in the query to skip the check entirely. + var applyCheckTypesFilter = checkTypes != null && !checkTypes.isEmpty(); + var applyScannerNamesFilter = scannerNames != null && !scannerNames.isEmpty(); + var checkTypesParam = applyCheckTypesFilter ? checkTypes : List.of(""); + var scannerNamesParam = applyScannerNamesFilter ? scannerNames : List.of(""); + + return adminScanDecisionRepo.countForStatistics( + decision, startedFrom, startedTo, checkTypesParam, applyCheckTypesFilter, + scannerNamesParam, applyScannerNamesFilter, enforcedOnly + ); + } + + public ExtensionValidationFailure saveValidationFailure(ExtensionValidationFailure failure) { + return extensionValidationFailureRepo.save(failure); + } + + public ExtensionValidationFailure findValidationFailure(long id) { + return extensionValidationFailureRepo.findById(id); + } + + public Streamable findValidationFailures(ExtensionScan scan) { + return extensionValidationFailureRepo.findByScan(scan); + } + + public List findDistinctValidationFailureRuleNames() { + return extensionValidationFailureRepo.findDistinctRuleNames(); + } + + public List findDistinctValidationFailureCheckTypes() { + return extensionValidationFailureRepo.findDistinctCheckTypes(); + } + + public Streamable findValidationFailuresByType(String checkType) { + return extensionValidationFailureRepo.findByCheckType(checkType); + } + + public Streamable findValidationFailures(ExtensionScan scan, String checkType) { + return extensionValidationFailureRepo.findByScanAndCheckType(scan, checkType); + } + + public long countValidationFailures(ExtensionScan scan) { + return extensionValidationFailureRepo.countByScan(scan); + } + + public long countValidationFailuresByType(String checkType) { + return extensionValidationFailureRepo.countByCheckType(checkType); + } + + public boolean hasValidationFailures(ExtensionScan scan) { + return extensionValidationFailureRepo.existsByScan(scan); + } + + public boolean hasValidationFailuresOfType(ExtensionScan scan, String checkType) { + return extensionValidationFailureRepo.existsByScanAndCheckType(scan, checkType); + } + + public AdminScanDecision saveAdminScanDecision(AdminScanDecision decision) { + return adminScanDecisionRepo.save(decision); + } + + public AdminScanDecision findAdminScanDecision(long id) { + return adminScanDecisionRepo.findById(id); + } + + public AdminScanDecision findAdminScanDecision(ExtensionScan scan) { + return adminScanDecisionRepo.findByScan(scan); + } + + public AdminScanDecision findAdminScanDecisionByScanId(long scanId) { + return adminScanDecisionRepo.findByScanId(scanId); + } + + public boolean hasAdminScanDecision(ExtensionScan scan) { + return adminScanDecisionRepo.existsByScan(scan); + } + + public boolean hasAdminScanDecisionByScanId(long scanId) { + return adminScanDecisionRepo.existsByScanId(scanId); + } + + public long countAdminScanDecisions(String decision) { + return adminScanDecisionRepo.countByDecision(decision); + } + + public long countAdminScanDecisionsByDateRange(String decision, LocalDateTime startedFrom, LocalDateTime startedTo) { + return adminScanDecisionRepo.countByDecisionAndDateRange(decision, startedFrom, startedTo); + } + + public long countAdminScanDecisionsByEnforcement(String decision, boolean enforcedOnly) { + return adminScanDecisionRepo.countByDecisionAndEnforcement(decision, enforcedOnly); + } + + public void deleteAdminScanDecision(long id) { + adminScanDecisionRepo.deleteById(id); + } + + public ExtensionThreat saveExtensionThreat(ExtensionThreat threat) { + return extensionThreatRepo.save(threat); + } + + public ExtensionThreat findExtensionThreat(long id) { + return extensionThreatRepo.findById(id); + } + + public Streamable findExtensionThreats(ExtensionScan scan) { + return extensionThreatRepo.findByScan(scan); + } + + public Streamable findExtensionThreatsByScanId(long scanId) { + return extensionThreatRepo.findByScanId(scanId); + } + + public Streamable findExtensionThreatsByFileHash(String fileHash) { + return extensionThreatRepo.findByFileHash(fileHash); + } + + public long countExtensionThreats(ExtensionScan scan) { + return extensionThreatRepo.countByScan(scan); + } + + public boolean hasExtensionThreats(ExtensionScan scan) { + return extensionThreatRepo.existsByScan(scan); + } + + public List findDistinctThreatScannerTypes() { + return extensionThreatRepo.findDistinctScannerTypes(); + } + + public List findDistinctThreatRuleNames() { + return extensionThreatRepo.findDistinctRuleNames(); + } + + public Streamable findExtensionThreatsByType(String type) { + return extensionThreatRepo.findByType(type); + } + + public Streamable findExtensionThreats(ExtensionScan scan, String type) { + return extensionThreatRepo.findByScanAndType(scan, type); + } + + public Streamable findExtensionThreatsAfter(LocalDateTime date) { + return extensionThreatRepo.findByDetectedAtAfter(date); + } + + public Streamable findExtensionThreatsOrdered(ExtensionScan scan) { + return extensionThreatRepo.findByScanOrderByDetectedAtAsc(scan); + } + + public long countExtensionThreatsByType(String type) { + return extensionThreatRepo.countByType(type); + } + + public boolean hasExtensionThreatsOfType(ExtensionScan scan, String type) { + return extensionThreatRepo.existsByScanAndType(scan, type); + } + + public FileDecision saveFileDecision(FileDecision decision) { + return fileDecisionRepo.save(decision); + } + + public FileDecision findFileDecision(long id) { + return fileDecisionRepo.findById(id); + } + + public FileDecision findFileDecisionByHash(String fileHash) { + return fileDecisionRepo.findByFileHash(fileHash); + } + + public boolean hasFileDecision(String fileHash) { + return fileDecisionRepo.existsByFileHash(fileHash); + } + + public long countFileDecisions(String decision) { + return fileDecisionRepo.countByDecision(decision); + } + + public long countAllFileDecisions() { + return fileDecisionRepo.count(); + } + + public long countFileDecisionsByDateRange(String decision, LocalDateTime decidedFrom, LocalDateTime decidedTo) { + return fileDecisionRepo.countByDecisionAndDateRange(decision, decidedFrom, decidedTo); + } + + public void deleteFileDecision(long id) { + fileDecisionRepo.deleteById(id); + } + + public void deleteFileDecisionByHash(String fileHash) { + fileDecisionRepo.deleteByFileHash(fileHash); + } + + public Page findFileDecisionsFiltered( + String decision, + String publisher, + String namespace, + String name, + LocalDateTime decidedFrom, + LocalDateTime decidedTo, + Pageable pageable + ) { + var decisionParam = (decision == null || decision.isBlank()) ? null : decision.toUpperCase(); + var publisherParam = (publisher == null || publisher.isBlank()) ? null : publisher; + var namespaceParam = (namespace == null || namespace.isBlank()) ? null : namespace; + var nameParam = (name == null || name.isBlank()) ? null : name; + + return fileDecisionRepo.findFilesFiltered( + decisionParam, publisherParam, namespaceParam, nameParam, decidedFrom, decidedTo, pageable + ); + } + + public List findFileDecisionsByIds(List ids) { + if (ids == null || ids.isEmpty()) { + return List.of(); + } + return fileDecisionRepo.findByIdIn(ids); + } } \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/ExtensionScanConfig.java b/server/src/main/java/org/eclipse/openvsx/scanning/ExtensionScanConfig.java new file mode 100644 index 000000000..9af8cfbe9 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/scanning/ExtensionScanConfig.java @@ -0,0 +1,45 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration for extension scanning during publishing. + * + * This controls the overall scan lifecycle and behavior. + * Individual validation checks (like secret scanning, similarity) have their own configs. + * + * Configuration example: + * ovsx: + * scanning: + * enabled: true # Enable extension scanning (default: false) + */ +@Configuration +public class ExtensionScanConfig { + + /** + * When disabled, no scanning during publishing is performed. + * Defaults to false - must be explicitly enabled in production. + */ + @Value("${ovsx.scanning.enabled:false}") + private boolean enabled; + + /** + * Check if extension scanning is enabled. + */ + public boolean isEnabled() { + return enabled; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/ExtensionScanPersistenceService.java b/server/src/main/java/org/eclipse/openvsx/scanning/ExtensionScanPersistenceService.java new file mode 100644 index 000000000..93f1d02e0 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/scanning/ExtensionScanPersistenceService.java @@ -0,0 +1,194 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import jakarta.transaction.Transactional; +import org.eclipse.openvsx.entities.*; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +import static jakarta.transaction.Transactional.TxType; + +/** + * Handles all transactional persistence operations for extension scans. + * This service is separate from ExtensionScanService to avoid self-invocation issues + * where @Transactional(TxType.REQUIRES_NEW) would be ignored. + * + * All methods use REQUIRES_NEW to ensure they commit independently, + * preserving the scan audit trail even when the outer transaction rolls back. + */ +@Service +public class ExtensionScanPersistenceService { + + private static final Logger logger = LoggerFactory.getLogger(ExtensionScanPersistenceService.class); + + private final RepositoryService repositories; + + public ExtensionScanPersistenceService( + RepositoryService repositories + ) { + this.repositories = repositories; + } + + /** + * Creates and persists a new scan record BEFORE an extension version exists. + */ + @Transactional(TxType.REQUIRES_NEW) + public ExtensionScan initializeScan( + String namespaceName, + String extensionName, + String version, + String targetPlatform, + String displayName, + UserData user + ) { + var isUniversal = targetPlatform == null || "universal".equals(targetPlatform); + if (displayName == null || displayName.isBlank()) { + displayName = extensionName; + } + + return initializeScanInternal( + namespaceName, + extensionName, + version, + targetPlatform, + isUniversal, + displayName, + user + ); + } + + /** + * Internal method that handles the actual scan creation logic. + */ + private ExtensionScan initializeScanInternal( + String namespaceName, + String extensionName, + String version, + String targetPlatform, + boolean isUniversal, + String displayName, + UserData user + ) { + try { + var scan = new ExtensionScan(); + // Store raw values instead of foreign keys for audit trail + scan.setNamespaceName(namespaceName); + scan.setExtensionName(extensionName); + scan.setExtensionVersion(version); + scan.setTargetPlatform(targetPlatform); + scan.setUniversalTargetPlatform(isUniversal); + scan.setExtensionDisplayName(displayName); + + var publisherLoginName = ""; + String publisherUrl = null; + if (user != null) { + publisherLoginName = user.getLoginName() != null ? user.getLoginName() : "unknown"; + publisherUrl = user.getProviderUrl(); + } + scan.setPublisher(publisherLoginName); + scan.setPublisherUrl(publisherUrl); + + scan.setStartedAt(LocalDateTime.now()); + scan.setStatus(ScanStatus.STARTED); + + return repositories.saveExtensionScan(scan); + } catch (Exception e) { + logger.error("FATAL: Failed to create extension scan", e); + throw e; + } + } + + /** + * Records a validation failure with the given check type. + */ + @Transactional(TxType.REQUIRES_NEW) + public void recordValidationFailure(ExtensionScan scan, String checkType, String ruleName, String reason, boolean enforced) { + var failure = ExtensionValidationFailure.create(checkType, ruleName, reason); + failure.setEnforced(enforced); + scan.addValidationFailure(failure); + repositories.saveValidationFailure(failure); + } + + /** + * Records a threat detected by a security scanner during long-running scans. + */ + @Transactional(TxType.REQUIRES_NEW) + public void recordThreat( + ExtensionScan scan, + String fileName, + String fileHash, + String fileExtension, + String scannerType, + String ruleName, + String reason, + String severity, + boolean enforced + ) { + var threat = ExtensionThreat.create( + fileName, + fileHash, + fileExtension, + scannerType, + ruleName, + reason, + severity + ); + threat.setEnforced(enforced); + scan.addThreat(threat); + repositories.saveExtensionThreat(threat); + } + + /** + * Persists a status change. The caller is responsible for validating the transition. + */ + @Transactional(TxType.REQUIRES_NEW) + public void updateStatus(ExtensionScan scan, ScanStatus newStatus) { + scan.setStatus(newStatus); + repositories.saveExtensionScan(scan); + } + + /** + * Persists a terminal status change with completion timestamp. + */ + @Transactional(TxType.REQUIRES_NEW) + public void completeWithStatus(ExtensionScan scan, ScanStatus newStatus) { + scan.setStatus(newStatus); + scan.setCompletedAt(LocalDateTime.now()); + repositories.saveExtensionScan(scan); + } + + /** + * Persists an error status with message. + */ + @Transactional(TxType.REQUIRES_NEW) + public void markAsErrored(ExtensionScan scan, String errorMessage) { + scan.setStatus(ScanStatus.ERRORED); + scan.setErrorMessage(errorMessage); + scan.setCompletedAt(LocalDateTime.now()); + repositories.saveExtensionScan(scan); + } + + /** + * Removes a scan. + */ + @Transactional(TxType.REQUIRES_NEW) + public void removeScan(ExtensionScan scan) { + repositories.deleteExtensionScan(scan); + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/ExtensionScanService.java b/server/src/main/java/org/eclipse/openvsx/scanning/ExtensionScanService.java new file mode 100644 index 000000000..a500726b1 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/scanning/ExtensionScanService.java @@ -0,0 +1,258 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import org.eclipse.openvsx.ExtensionProcessor; +import org.eclipse.openvsx.entities.*; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.TempFile; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * Service for managing extension scans and recording artifacts. + * + * Responsibilities: + * - Owns the scan lifecycle state machine + * - Delegates actual scanning to ExtensionScanner + * - Records scan results and failures via ExtensionScanPersistenceService + * - Provides query methods for scan data + */ +@Component +public class ExtensionScanService { + + private static final Logger logger = LoggerFactory.getLogger(ExtensionScanService.class); + + private final ExtensionScanConfig config; + private final ExtensionScanner scanner; + private final ExtensionScanPersistenceService persistenceService; + + public ExtensionScanService( + ExtensionScanConfig config, + ExtensionScanner scanner, + ExtensionScanPersistenceService persistenceService + ) { + this.config = config; + this.scanner = scanner; + this.persistenceService = persistenceService; + } + + /** + * Check if extension scanning is enabled. + * @return true if scanning is enabled via configuration + */ + public boolean isEnabled() { + return config.isEnabled(); + } + + /** + * Creates a scan record from ExtensionProcessor metadata. + * Use this when validation runs BEFORE extension creation. + */ + public ExtensionScan initializeScan(ExtensionProcessor processor, UserData user) { + return initializeScan( + processor.getNamespace(), + processor.getExtensionName(), + processor.getVersion(), + processor.getTargetPlatform(), + processor.getDisplayName(), + user + ); + } + + private ExtensionScan initializeScan( + String namespaceName, + String extensionName, + String version, + String targetPlatform, + String displayName, + UserData user + ) { + logger.debug("Starting publish scan for {}.{} v{}", namespaceName, extensionName, version); + + return persistenceService.initializeScan( + namespaceName, + extensionName, + version, + targetPlatform, + displayName, + user + ); + } + + /** + * Run validation checks and record results. + * + * Delegates the actual check execution to ExtensionScanner, + * then records findings and manages state transitions. + */ + public void runValidation( + ExtensionScan scan, + TempFile extensionFile, + UserData user + ) { + transitionTo(scan, ScanStatus.VALIDATING); + + var scanResult = scanner.runValidationChecks(scan, extensionFile, user); + + if (scanResult.hasError()) { + markScanAsErrored(scan, scanResult.getErrorMessage()); + throw new ErrorResultException(scanResult.getErrorMessage(), scanResult.error()); + } + + // Record all findings in the database. + for (var finding : scanResult.findings()) { + persistenceService.recordValidationFailure( + scan, + finding.checkType(), + finding.ruleName(), + finding.reason(), + finding.enforced() + ); + } + + // Handle enforced failures - block publication. + if (scanResult.hasEnforcedFailure()) { + transitionToTerminal(scan, ScanStatus.REJECTED); + logger.info("Publication blocked due to policy violations: {}.{}", + scan.getNamespaceName(), scan.getExtensionName()); + + var enforcedFailures = scan.getValidationFailures() + .stream() + .filter(ExtensionValidationFailure::isEnforced) + .toList(); + throw new ErrorResultException(getValidationFailuresErrorMessage(enforcedFailures)); + } + + if (!scanResult.getWarningFindings().isEmpty()) { + logger.warn("Policy violations detected but not enforced: {}.{}", + scan.getNamespaceName(), scan.getExtensionName()); + } + + logger.debug("Scan {} - Validation passed", scan.getId()); + } + + /** + * Runs the long running scanning process for an extension. + * + * Delegates the actual scan execution to ExtensionScanner, + * then records findings and manages state transitions. + */ + public void runScan(ExtensionScan scan, TempFile extensionFile, UserData user) { + transitionTo(scan, ScanStatus.SCANNING); + // TODO: Implement the scanning process + scanner.runScanners(scan, extensionFile, user); + } + + public void completeScanningSuccess(ExtensionScan scan) { + if (scan == null) return; + if (scan.getStatus().isCompleted()) { + logger.debug("Scan {} already completed with status {}, skipping success marking", + scan.getId(), scan.getStatus()); + return; + } + transitionToTerminal(scan, ScanStatus.PASSED); + } + + public void quarantineScan(ExtensionScan scan) { + if (scan == null) return; + if (scan.getStatus().isCompleted()) { + logger.debug("Scan {} already completed with status {}, skipping quarantine marking", + scan.getId(), scan.getStatus()); + return; + } + transitionToTerminal(scan, ScanStatus.QUARANTINED); + } + + public void markScanAsErrored(ExtensionScan scan, String errorMessage) { + if (scan == null) return; + if (scan.getStatus().isCompleted()) { + logger.debug("Scan {} already completed with status {}, skipping error marking", + scan.getId(), scan.getStatus()); + return; + } + validateTransition(scan.getStatus(), ScanStatus.ERRORED); + persistenceService.markAsErrored(scan, errorMessage); + } + + private void transitionTo(ExtensionScan scan, ScanStatus newStatus) { + validateTransition(scan.getStatus(), newStatus); + persistenceService.updateStatus(scan, newStatus); + } + + private void transitionToTerminal(ExtensionScan scan, ScanStatus newStatus) { + validateTransition(scan.getStatus(), newStatus); + persistenceService.completeWithStatus(scan, newStatus); + } + + private void validateTransition(ScanStatus from, ScanStatus to) { + if (!isValidTransition(from, to)) { + throw new IllegalStateException(String.format( + "Invalid scan state transition: %s -> %s", from, to + )); + } + } + + private boolean isValidTransition(ScanStatus from, ScanStatus to) { + if (from.isCompleted()) { + return false; + } + + return switch (from) { + // Any in-progress state can end with an error. + case STARTED -> to == ScanStatus.VALIDATING || to == ScanStatus.SCANNING || to == ScanStatus.ERRORED; + case VALIDATING -> to == ScanStatus.SCANNING || to == ScanStatus.REJECTED || to == ScanStatus.ERRORED; + case SCANNING -> to == ScanStatus.PASSED || to == ScanStatus.QUARANTINED || to == ScanStatus.ERRORED; + default -> false; + }; + } + + public void removeScan(ExtensionScan scan) { + persistenceService.removeScan(scan); + } + + private String getValidationFailuresErrorMessage(List failures) { + if (failures.isEmpty()) { + return "Extension publication blocked."; + } + + var failuresByType = failures.stream() + .collect(Collectors.groupingBy(ExtensionValidationFailure::getCheckType)); + + var parts = new java.util.ArrayList(); + for (var entry : failuresByType.entrySet()) { + var typeFailures = entry.getValue(); + + int maxToShow = Math.min(3, typeFailures.size()); + var reasons = typeFailures.stream() + .limit(maxToShow) + .map(ExtensionValidationFailure::getValidationFailureReason) + .collect(Collectors.joining(", ")); + + var part = new StringBuilder(); + part.append(reasons); + if (typeFailures.size() > maxToShow) { + part.append(" ... and ").append(typeFailures.size() - maxToShow).append(" more"); + } + parts.add(part.toString()); + } + + return "Extension publication blocked: " + String.join(", ", parts) + ". For details on " + + "publishing extensions, see: https://github.com/eclipse/openvsx/wiki/Publishing-Extensions"; + } +} \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/ExtensionScanner.java b/server/src/main/java/org/eclipse/openvsx/scanning/ExtensionScanner.java new file mode 100644 index 000000000..213878994 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/scanning/ExtensionScanner.java @@ -0,0 +1,180 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import org.eclipse.openvsx.entities.ExtensionScan; +import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.util.TempFile; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * Handles the scanning logic for extensions. + * + * This class is responsible for: + * - Running validation checks against extension files + * - Collecting and aggregating check results + * + * It does NOT handle: + * - Persistence or State transitions - that's the responsibility of the {@link ExtensionScanService} + */ +@Component +public class ExtensionScanner { + + private static final Logger logger = LoggerFactory.getLogger(ExtensionScanner.class); + + private final List validationChecks; + + public ExtensionScanner(List validationChecks) { + this.validationChecks = validationChecks; + + logger.info("ExtensionScanner initialized with {} validation checks: {}", + validationChecks.size(), + validationChecks.stream().map(ValidationCheck::getCheckType).toList()); + } + + /** + * Run all validation checks and return the aggregated result. + * + * This method does not persist anything. It only executes checks and returns findings. + * The caller is responsible for recording failures and managing state. + */ + public ScanResult runValidationChecks( + ExtensionScan scan, + TempFile extensionFile, + UserData user + ) { + var context = new ValidationCheck.Context(scan, extensionFile, user); + var allFindings = new ArrayList(); + boolean hasEnforcedFailure = false; + Exception checkError = null; + String errorCheckType = null; + + for (var check : validationChecks) { + // Skip disabled checks. + if (!check.isEnabled()) { + logger.debug("Scan {} - Skipping disabled check: {}", scan.getId(), check.getCheckType()); + continue; + } + + logger.debug("Scan {} - Running check: {}", scan.getId(), check.getCheckType()); + + try { + var result = check.check(context); + logger.debug("Scan {} - Check {} passed: {}", scan.getId(), check.getCheckType(), result.passed()); + + if (!result.passed()) { + boolean enforced = check.isEnforced(); + + // Convert each failure to a CheckFinding. + for (var failure : result.failures()) { + allFindings.add(new CheckFinding( + check.getCheckType(), + failure.ruleName(), + failure.reason(), + enforced + )); + } + + if (enforced) { + hasEnforcedFailure = true; + } + + logger.debug("Scan {} - {} detected {} issue(s), enforced={}", + scan.getId(), check.getCheckType(), result.failures().size(), enforced); + } + } catch (Exception e) { + // Capture the error but don't throw yet. + // Let the caller decide how to handle it. + checkError = e; + errorCheckType = check.getCheckType(); + logger.warn("Scan {} - {} check threw exception: {}", + scan.getId(), check.getCheckType(), e.getMessage()); + break; + } + } + + return new ScanResult(allFindings, hasEnforcedFailure, checkError, errorCheckType); + } + + public void runScanners(ExtensionScan scan, TempFile extensionFile, UserData user) { + // TODO: Implement scan checks. + } + + /** + * Result of running all validation checks. + */ + public record ScanResult( + List findings, + boolean hasEnforcedFailure, + Exception error, + String errorCheckType + ) { + /** + * Check if all validation passed (no enforced failures and no errors). + */ + public boolean passed() { + return !hasEnforcedFailure && error == null; + } + + /** + * Check if there was an error during validation. + */ + public boolean hasError() { + return error != null; + } + + /** + * Get the error message if there was an error. + */ + public String getErrorMessage() { + if (error == null) { + return null; + } + return error.getMessage() + " For details, see: https://github.com/eclipse/openvsx/wiki/Publishing-Extensions"; + } + + /** + * Get findings that are enforced (would block publication). + */ + public List getEnforcedFindings() { + return findings.stream() + .filter(CheckFinding::enforced) + .toList(); + } + + /** + * Get findings that are not enforced (warnings only). + */ + public List getWarningFindings() { + return findings.stream() + .filter(f -> !f.enforced()) + .toList(); + } + } + + /** + * A single finding from a validation check. + * Contains all info needed for the service to record the failure. + */ + public record CheckFinding( + String checkType, + String ruleName, + String reason, + boolean enforced + ) {} +} diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/GitleaksRulesGenerator.java b/server/src/main/java/org/eclipse/openvsx/scanning/GitleaksRulesGenerator.java index f72fefad6..a5e05d345 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/GitleaksRulesGenerator.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/GitleaksRulesGenerator.java @@ -18,6 +18,7 @@ import jakarta.annotation.PostConstruct; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import java.io.File; @@ -44,8 +45,11 @@ * - ovsx.secret-scanning.enabled: Must be true to generate rules * - ovsx.secret-scanning.auto-generate-rules: Enable automatic generation (default: false) * - ovsx.secret-scanning.force-regenerate-rules: Force regeneration even if file exists (default: false) + * + * Only loaded when auto-generate-rules is enabled via configuration. */ @Component +@ConditionalOnProperty(name = "ovsx.secret-scanning.auto-generate-rules", havingValue = "true") public class GitleaksRulesGenerator { private static final Logger logger = LoggerFactory.getLogger(GitleaksRulesGenerator.class); @@ -89,7 +93,6 @@ public String getGeneratedRulesPath() { public void generateRulesIfNeeded() { // Skip if auto-generation is disabled if (!config.isAutoGenerateRules()) { - logger.info("Auto-generation of secret rules is disabled"); return; } @@ -110,10 +113,6 @@ public void generateRulesIfNeeded() { return; } - // Generate rules - String action = outputFile.exists() ? "Regenerating" : "Generating"; - logger.info("{} secret scanning rules from gitleaks.toml...", action); - generateRules(outputFile.toPath()); // Verify the file was created successfully @@ -144,7 +143,7 @@ public void generateRulesIfNeeded() { */ private void generateRules(Path outputPath) throws IOException, InterruptedException { // Download TOML - logger.info("Downloading gitleaks.toml from: {}", GITLEAKS_URL); + logger.debug("Downloading gitleaks.toml from: {}", GITLEAKS_URL); String tomlContent = downloadGitleaksToml(); GitleaksToml parsed = parseTomlWithJackson(tomlContent); @@ -157,7 +156,6 @@ private void generateRules(Path outputPath) throws IOException, InterruptedExcep configDto.rules = rules; configDto.allowlist = allowlist; - logger.info("Writing YAML to: {}", outputPath); writeYaml(configDto, outputPath); } @@ -207,7 +205,7 @@ private List buildRules(List rawRules) { for (RawRule raw : rawRules) { // Skip rules known to cause false positives if (raw.id != null && SKIP_RULE_IDS.contains(raw.id)) { - logger.info("Skipping rule: {} (known to cause false positives)", raw.id); + logger.debug("Skipping rule: {} (known to cause false positives)", raw.id); skipped++; continue; } @@ -404,7 +402,7 @@ private File resolveOutputFile() { if (parentDir != null && !parentDir.exists()) { try { Files.createDirectories(parentDir.toPath()); - logger.info("Created directory for generated rules: {}", parentDir.getAbsolutePath()); + logger.debug("Created directory for generated rules: {}", parentDir.getAbsolutePath()); } catch (IOException e) { throw new IllegalStateException( "Cannot create directory for generated rules: " + parentDir.getAbsolutePath() + @@ -419,7 +417,7 @@ private File resolveOutputFile() { ". Check file permissions."); } - logger.info("Using configured path for generated rules: {}", outputFile.getAbsolutePath()); + logger.debug("Using configured path for generated rules: {}", outputFile.getAbsolutePath()); return outputFile; } diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/SecretFinding.java b/server/src/main/java/org/eclipse/openvsx/scanning/SecretFinding.java index fbdea274c..5413f8614 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/SecretFinding.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/SecretFinding.java @@ -61,11 +61,11 @@ public double getEntropy() { @Override public String toString() { return String.format( - "Potential secret found in %s:%d (entropy: %.2f, rule: %s): %s", + "Potential secret found in %s:%d (rule: %s, entropy: %f): %s", filePath, lineNumber, - entropy, ruleId, + entropy, redactedSecret ); } diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/SecretRuleLoader.java b/server/src/main/java/org/eclipse/openvsx/scanning/SecretRuleLoader.java index 97bb8d6c8..6431a8f9e 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/SecretRuleLoader.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/SecretRuleLoader.java @@ -19,6 +19,7 @@ import javax.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Component; @@ -33,8 +34,10 @@ /** * Loads secret detection rules from a YAML file. + * Only loaded when secret scanning is enabled via configuration. */ @Component +@ConditionalOnProperty(name = "ovsx.secret-scanning.enabled", havingValue = "true") public class SecretRuleLoader { private static final Logger logger = LoggerFactory.getLogger(SecretRuleLoader.class); @@ -88,8 +91,7 @@ public List load(@NotNull String path) { */ public LoadedRules loadAll(@NotNull List paths) { if (paths.isEmpty()) { - var message = "Secret scanning rules path list is empty"; - logger.warn(message); + logger.warn("Secret scanning rules path list is empty"); return new LoadedRules(List.of(), null); } diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanner.java b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanner.java index 7b430fa55..48873b470 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanner.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanner.java @@ -163,7 +163,7 @@ boolean scanFile(@NotNull ZipFile zipFile, scanLineWithKeywordMatching(line, line.toLowerCase(), filePath, lineNumber, findings, findingsCount, recorder); } } catch (IOException e) { - logger.warn("Error reading file {}: {}", filePath, e.getMessage()); + logger.error("Error reading file {}: {}", filePath, e.getMessage()); throw e; } diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/SecretScannerFactory.java b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScannerFactory.java index d398c4a4d..f4397646f 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/SecretScannerFactory.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScannerFactory.java @@ -18,7 +18,7 @@ import javax.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.context.annotation.DependsOn; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import java.util.ArrayList; @@ -36,11 +36,12 @@ * If neither is configured, initialization is skipped, allowing the application * to start without requiring secret scanning infrastructure. * - * This component depends on {@link GitleaksRulesGenerator} to ensure rules - * are generated (if configured) before we try to load them. + * If auto-generation is enabled, this depends on {@link GitleaksRulesGenerator} + * to ensure rules are generated before we try to load them. + * Only loaded when secret scanning is enabled via configuration. */ @Component -@DependsOn("gitleaksRulesGenerator") +@ConditionalOnProperty(name = "ovsx.secret-scanning.enabled", havingValue = "true") public class SecretScannerFactory { private static final Logger logger = LoggerFactory.getLogger(SecretScannerFactory.class); @@ -57,7 +58,7 @@ public class SecretScannerFactory { public SecretScannerFactory( @NotNull SecretRuleLoader ruleLoader, @NotNull SecretScanningConfig config, - @NotNull GitleaksRulesGenerator generator) { + @Nullable GitleaksRulesGenerator generator) { this.ruleLoader = ruleLoader; this.config = config; this.generator = generator; @@ -69,7 +70,6 @@ public void initialize() { List rulePaths = buildRulePaths(); // Skip initialization if there are no rule paths to load - // This happens when secret scanning is not configured at all if (rulePaths.isEmpty()) { logger.info("No secret scanning rules configured; skipping scanner initialization"); return; @@ -271,19 +271,17 @@ private List getInlineSuppressions(@NotNull SecretScanningConfig config) /** * Build the list of rule paths to load. * If auto-generation is enabled and succeeded, prepend the generated file path. - * Otherwise, use the configured paths from application.yml. + * Append the configured paths from the application.yml. */ private List buildRulePaths() { List paths = new ArrayList<>(); - // If auto-generation is enabled and succeeded, use the generated file - if (config.isAutoGenerateRules()) { + // If auto-generation is enabled and the generator bean exists, use the generated file + if (generator != null) { String generatedPath = generator.getGeneratedRulesPath(); if (generatedPath != null) { logger.debug("Using auto-generated rules file: {}", generatedPath); paths.add(generatedPath); - } else { - logger.warn("Auto-generation was enabled but no rules file was generated"); } } diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningConfig.java b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningConfig.java index 3742f2464..d74a311e0 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningConfig.java @@ -1,14 +1,14 @@ /******************************************************************************** - * Copyright (c) 2025 Contributors to the Eclipse Foundation + * Copyright (c) 2025 Contributors to the Eclipse Foundation * - * See the NOTICE file(s) distributed with this work for additional + * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. * - * This program and the accompanying materials are made available under the + * This program and the accompanying materials are made available under the * terms of the Eclipse Public License 2.0 which is available at * https://www.eclipse.org/legal/epl-2.0 * - * SPDX-License-Identifier: EPL-2.0 + * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ package org.eclipse.openvsx.scanning; @@ -22,9 +22,6 @@ /** * Configuration for secret scanning in extension packages. - * - * This service scans VSIX files for potential secrets like API keys, tokens, and passwords - * before allowing publication. It uses regex patterns and entropy calculation to detect secrets. */ @Configuration public class SecretScanningConfig { @@ -78,6 +75,19 @@ public class SecretScanningConfig { @Value("${ovsx.secret-scanning.generated-rules-path:}") private String generatedRulesPath; + /** + * Whether secret scan findings are enforced (i.e. block publishing) when detected. + * + * Why this exists: + * - We sometimes want to run secret scanning and record audit data, + * but not reject publication (monitor-only mode). + * + * Default is true to preserve historic behavior: when secret scanning is enabled, + * findings will block publishing unless explicitly configured otherwise. + */ + @Value("${ovsx.secret-scanning.enforced:true}") + private boolean enforced; + /** * Maximum file size to scan in bytes. Files larger than this are skipped. * @@ -194,6 +204,10 @@ public boolean isEnabled() { return enabled; } + public boolean isEnforced() { + return enforced; + } + public long getMaxFileSizeBytes() { return maxFileSizeBytes; } @@ -276,37 +290,31 @@ public String getGeneratedRulesPath() { @PostConstruct public void validate() { - // Max file size should be positive if (maxFileSizeBytes <= 0) { throw new IllegalArgumentException( "ovsx.secret-scanning.max-file-size-bytes must be positive, got: " + maxFileSizeBytes); } - // Max line length should be positive if (maxLineLength <= 0) { throw new IllegalArgumentException( "ovsx.secret-scanning.max-line-length must be positive, got: " + maxLineLength); } - // Timeout should be positive if (timeoutSeconds <= 0) { throw new IllegalArgumentException( "ovsx.secret-scanning.timeout-seconds must be positive, got: " + timeoutSeconds); } - // Entry cap must be positive if (maxEntryCount <= 0) { throw new IllegalArgumentException( "ovsx.secret-scanning.max-entry-count must be positive, got: " + maxEntryCount); } - // Total size cap must be positive if (maxTotalUncompressedBytes <= 0) { throw new IllegalArgumentException( "ovsx.secret-scanning.max-total-uncompressed-bytes must be positive, got: " + maxTotalUncompressedBytes); } - // Findings cap must be positive if (maxFindings <= 0) { throw new IllegalArgumentException( "ovsx.secret-scanning.max-findings must be positive, got: " + maxFindings); diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningService.java b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningService.java index 3237f913e..39cd674a9 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningService.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/SecretScanningService.java @@ -1,14 +1,14 @@ /******************************************************************************** - * Copyright (c) 2025 Contributors to the Eclipse Foundation + * Copyright (c) 2025 Contributors to the Eclipse Foundation * - * See the NOTICE file(s) distributed with this work for additional + * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. * - * This program and the accompanying materials are made available under the + * This program and the accompanying materials are made available under the * terms of the Eclipse Public License 2.0 which is available at * https://www.eclipse.org/legal/epl-2.0 * - * SPDX-License-Identifier: EPL-2.0 + * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ package org.eclipse.openvsx.scanning; @@ -16,10 +16,9 @@ import org.eclipse.openvsx.util.ArchiveUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.core.task.AsyncTaskExecutor; import org.springframework.stereotype.Service; -import org.eclipse.openvsx.util.ErrorResultException; -import org.springframework.http.HttpStatus; import jakarta.validation.constraints.NotNull; import java.io.IOException; @@ -39,9 +38,14 @@ * passwords, and other sensitive credentials that should not be published publicly. * * Uses Spring's default async executor for parallel file scanning within extension packages. + * Implements ValidationCheck to be auto-discovered by ExtensionScanService. + * Only loaded when secret scanning is enabled via configuration. */ @Service -public class SecretScanningService { +@ConditionalOnProperty(name = "ovsx.secret-scanning.enabled", havingValue = "true") +public class SecretScanningService implements ValidationCheck { + + public static final String CHECK_TYPE = "SECRET"; private static final Logger logger = LoggerFactory.getLogger(SecretScanningService.class); @@ -70,20 +74,45 @@ public SecretScanningService( this.config = config; this.taskExecutor = taskExecutor; - // Cache configuration values that are reused during scanning this.maxEntryCount = config.getMaxEntryCount(); this.maxTotalUncompressedBytes = config.getMaxTotalUncompressedBytes(); this.maxFindings = config.getMaxFindings(); this.fileContentScanner = scannerFactory.getScanner(); } + + @Override + public boolean isEnforced() { + return config.isEnforced(); + } - /** - * Returns whether secret scanning is enabled. - */ + @Override public boolean isEnabled() { return config.isEnabled(); } + + @Override + public String getCheckType() { + return CHECK_TYPE; + } + + @Override + public ValidationCheck.Result check(ValidationCheck.Context context) { + if (context.extensionFile() == null) { + return ValidationCheck.Result.pass(); + } + + var scanResult = scanForSecrets(context.extensionFile()); + if (!scanResult.isSecretsFound()) { + return ValidationCheck.Result.pass(); + } + + var failures = scanResult.getFindings().stream() + .map(f -> new ValidationCheck.Failure(f.getRuleId(), f.toString())) + .toList(); + + return ValidationCheck.Result.fail(failures); + } /** * Scans an extension package for potential secrets. @@ -140,7 +169,7 @@ public SecretScanResult scanForSecrets(@NotNull TempFile extensionFile) { } catch (SecretScanningTimeoutException | ScanCancelledException e) { throw e; } catch (Exception e) { - logger.warn("Failed to scan file {}: {}", filePath, e.getMessage()); + logger.error("Failed to scan file {}: {}", filePath, e.getMessage()); } return null; })); @@ -173,16 +202,18 @@ public SecretScanResult scanForSecrets(@NotNull TempFile extensionFile) { } catch (SecretScanningTimeoutException e) { logger.error("Secret scanning timed out after {} seconds", config.getTimeoutSeconds()); - throw new ErrorResultException( - "Secret scanning timed out after " + config.getTimeoutSeconds() - + " seconds. Please reduce the file size or exclude large files.", - HttpStatus.REQUEST_TIMEOUT); + throw new RuntimeException( + "Secret scanning timed out after " + config.getTimeoutSeconds() + " seconds. " + + "Please reduce the file size or exclude large files.", e); } catch (ZipException e) { logger.error("Failed to open extension file as zip: {}", e.getMessage()); - throw new ErrorResultException("Failed to scan extension file: invalid zip format", HttpStatus.BAD_REQUEST); + throw new RuntimeException("Failed to scan extension file: invalid zip format", e); } catch (IOException e) { logger.error("Failed to scan extension file: {}", e.getMessage()); - throw new ErrorResultException("Failed to scan extension file", HttpStatus.BAD_REQUEST); + throw new RuntimeException("Failed to scan extension file: " + e.getMessage(), e); + } catch (Exception e) { + logger.error("Failed to scan extension file: {}", e.getMessage()); + throw new RuntimeException("Failed to scan extension file: " + e.getMessage(), e); } if (findings.isEmpty()) { diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/StaleScanRecovery.java b/server/src/main/java/org/eclipse/openvsx/scanning/StaleScanRecovery.java new file mode 100644 index 000000000..c9db8221d --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/scanning/StaleScanRecovery.java @@ -0,0 +1,75 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import jakarta.annotation.PostConstruct; +import org.eclipse.openvsx.entities.ScanStatus; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.Arrays; + +/** + * Recovers scans that were left in non-terminal states after a server restart. + * Runs once on application startup. + */ +@Component +public class StaleScanRecovery { + + private static final Logger logger = LoggerFactory.getLogger(StaleScanRecovery.class); + + private final RepositoryService repositories; + private final ExtensionScanPersistenceService persistenceService; + + public StaleScanRecovery( + RepositoryService repositories, + ExtensionScanPersistenceService persistenceService + ) { + this.repositories = repositories; + this.persistenceService = persistenceService; + } + + @PostConstruct + public void recoverStaleScans() { + + Arrays.stream(ScanStatus.values()) + .filter(status -> !status.isCompleted()) + .forEach(status -> { + var staleScans = repositories.findExtensionScansByStatus(status).toList(); + int recoveredCount = 0; + + for (var scan : staleScans) { + var message = String.format( + "Scan interrupted by server restart (was %s)", + status + ); + + try { + persistenceService.markAsErrored(scan, message); + recoveredCount++; + } catch (Exception e) { + logger.error("Failed to recover stale scan {}: {}", + scan.getId(), e.getMessage()); + } + } + + if (recoveredCount > 0) { + logger.info("Recovered {} stale scan(s) on startup", recoveredCount); + } + }); + + } +} + diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/ValidationCheck.java b/server/src/main/java/org/eclipse/openvsx/scanning/ValidationCheck.java new file mode 100644 index 000000000..f757775c3 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/scanning/ValidationCheck.java @@ -0,0 +1,104 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import org.eclipse.openvsx.entities.ExtensionScan; +import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.util.TempFile; +import org.springframework.lang.NonNull; + +import java.util.List; + +/** + * Interface for validation checks that run during extension publishing. + * + * Implementations are auto-discovered by Spring and executed by ExtensionScanService. + * To add a new validation check, create a @Component that implements this interface. + */ +public interface ValidationCheck { + + /** + * Unique identifier for this check type. + * Used for recording failures and filtering in the admin UI. + */ + String getCheckType(); + + /** + * Whether this check is enabled. Disabled checks are skipped entirely. + */ + boolean isEnabled(); + + /** + * Whether failures from this check should block publication. + * Non-enforced checks record failures for monitoring but allow publication to proceed. + */ + boolean isEnforced(); + + /** + * Execute the validation check. + */ + Result check(Context context); + + /** + * Context passed to validation checks during extension publishing. + * + * Contains the scan record (with extension metadata), the extension file, + * and the publishing user. Use scan.getNamespaceName(), scan.getExtensionName(), + * etc. to access extension metadata. + */ + record Context( + @NonNull ExtensionScan scan, + @NonNull TempFile extensionFile, + @NonNull UserData user + ) { + public Context { + if (scan == null) { + throw new IllegalArgumentException("scan cannot be null"); + } + if (extensionFile == null) { + throw new IllegalArgumentException("extensionFile cannot be null"); + } + if (user == null) { + throw new IllegalArgumentException("user cannot be null"); + } + } + } + + /** + * Result of a validation check execution. + */ + record Result( + boolean passed, + List failures + ) { + public static Result pass() { + return new Result(true, List.of()); + } + + public static Result fail(List failures) { + return new Result(false, failures); + } + + public static Result fail(String ruleName, String reason) { + return new Result(false, List.of(new Failure(ruleName, reason))); + } + } + + /** + * A single validation failure with details for recording. + */ + record Failure( + String ruleName, + String reason + ) {} +} diff --git a/server/src/main/java/org/eclipse/openvsx/search/SimilarityCheckService.java b/server/src/main/java/org/eclipse/openvsx/search/SimilarityCheckService.java index a49cb5ae3..75f6fe5f8 100644 --- a/server/src/main/java/org/eclipse/openvsx/search/SimilarityCheckService.java +++ b/server/src/main/java/org/eclipse/openvsx/search/SimilarityCheckService.java @@ -18,6 +18,8 @@ import org.eclipse.openvsx.entities.NamespaceMembership; import org.eclipse.openvsx.entities.UserData; import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.scanning.ValidationCheck; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; import javax.annotation.Nullable; @@ -27,9 +29,13 @@ * Central entry point for enforcing similarity rules with configuration-based policy. * * This service reads {@link SimilarityConfig} and applies all policy decisions. + * Implements ValidationCheck to be auto-discovered by ExtensionScanService. */ @Service -public class SimilarityCheckService { +@ConditionalOnProperty(name = "ovsx.similarity.enabled", havingValue = "true") +public class SimilarityCheckService implements ValidationCheck { + + public static final String CHECK_TYPE = "NAME_SQUATTING"; private static final int LIMIT = 10; @@ -47,13 +53,67 @@ public SimilarityCheckService( this.repositories = repositories; } - /** - * Returns whether similarity checking is enabled. - */ + @Override public boolean isEnabled() { return config.isEnabled(); } + @Override + public boolean isEnforced() { + return config.isEnforced(); + } + + @Override + public String getCheckType() { + return CHECK_TYPE; + } + + @Override + public ValidationCheck.Result check(ValidationCheck.Context context) { + var scan = context.scan(); + var namespaceName = scan.getNamespaceName(); + var extensionName = scan.getExtensionName(); + var displayName = scan.getExtensionDisplayName(); + + if (config.isNewExtensionsOnly() && repositories.countVersions(namespaceName, extensionName) > 1) { + return ValidationCheck.Result.pass(); + } + + if (config.isSkipVerifiedPublishers()) { + var namespace = repositories.findNamespace(namespaceName); + if (namespace != null && repositories.hasMemberships(namespace, NamespaceMembership.ROLE_OWNER)) { + return ValidationCheck.Result.pass(); + } + } + + var similarExtensions = findSimilarExtensionsForPublishing( + extensionName, + namespaceName, + displayName, + context.user() + ); + + if (similarExtensions.isEmpty()) { + return ValidationCheck.Result.pass(); + } + + var similarExt = similarExtensions.get(0); + var latestVersion = repositories.findLatestVersion(similarExt, null, false, true); + String similarDisplayName = latestVersion != null ? latestVersion.getDisplayName() : null; + + var reason = String.format( + "Extension '%s.%s' (display name: '%s') is too similar to existing extension '%s.%s' (display name: '%s')", + namespaceName, + extensionName, + displayName, + similarExt.getNamespace().getName(), + similarExt.getName(), + similarDisplayName != null ? similarDisplayName : "" + ); + + return ValidationCheck.Result.fail("Levenshtein Distance", reason); + } + /** * Enforce configured similarity rules for publishing an extension. * Callers should check {@link #isEnabled()} before invoking this method. @@ -64,19 +124,6 @@ public List findSimilarExtensionsForPublishing( @Nullable String displayName, @NotNull UserData publishingUser ) { - if (config.isNewExtensionsOnly() && namespaceName != null && extensionName != null) { - if (repositories.countVersions(namespaceName, extensionName) > 0) { - return List.of(); - } - } - - if (config.isSkipVerifiedPublishers() && namespaceName != null) { - var namespace = repositories.findNamespace(namespaceName); - if (namespace != null && repositories.hasMemberships(namespace, NamespaceMembership.ROLE_OWNER)) { - return List.of(); - } - } - return similarityService.findSimilarExtensions( extensionName, namespaceName, diff --git a/server/src/main/java/org/eclipse/openvsx/search/SimilarityConfig.java b/server/src/main/java/org/eclipse/openvsx/search/SimilarityConfig.java index 14b848f20..9ff8563fc 100644 --- a/server/src/main/java/org/eclipse/openvsx/search/SimilarityConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/search/SimilarityConfig.java @@ -50,6 +50,20 @@ public class SimilarityConfig { @Value("${ovsx.similarity.new-extensions-only:false}") private boolean newExtensionsOnly; + /** + * Whether similarity failures are enforced (i.e. block publishing) when a similarity match is found. + * + * Why this exists: + * - We sometimes want to run the check and store the audit trail (scan + failures) + * without rejecting publication (monitor-only mode). + * + * Default is true to preserve the historic behavior: when the similarity check is enabled, + * it blocks publishing on matches unless explicitly configured otherwise. + */ + @Value("${ovsx.similarity.enforced:true}") + private boolean enforced; + + /** * Levenshtein threshold used to decide whether two extension identifiers are "too similar". * The check compares the edit distance against a fraction of the identifier length. @@ -99,6 +113,10 @@ public boolean isEnabled() { public boolean isNewExtensionsOnly() { return newExtensionsOnly; } + + public boolean isEnforced() { + return enforced; + } public double getLevenshteinThreshold() { return levenshteinThreshold; diff --git a/server/src/main/resources/db/migration/V1_59__Extension_Scan_Tables.sql b/server/src/main/resources/db/migration/V1_59__Extension_Scan_Tables.sql new file mode 100644 index 000000000..1ae617ca0 --- /dev/null +++ b/server/src/main/resources/db/migration/V1_59__Extension_Scan_Tables.sql @@ -0,0 +1,219 @@ +-- Create tables for extension scanning and validation system +-- These tables track the lifecycle of extension validation and malware scanning + +-- ============================================================================ +-- SEQUENCES +-- ============================================================================ + +CREATE SEQUENCE IF NOT EXISTS extension_scan_seq START WITH 1 INCREMENT BY 1; +CREATE SEQUENCE IF NOT EXISTS extension_validation_failure_seq START WITH 1 INCREMENT BY 1; +CREATE SEQUENCE IF NOT EXISTS extension_threat_seq START WITH 1 INCREMENT BY 1; +CREATE SEQUENCE IF NOT EXISTS admin_scan_decision_seq START WITH 1 INCREMENT BY 1; +CREATE SEQUENCE IF NOT EXISTS file_decision_seq START WITH 1 INCREMENT BY 1; + +-- ============================================================================ +-- EXTENSION_SCAN TABLE +-- ============================================================================ + +-- Main scan record table +-- Tracks complete lifecycle from upload through validation, scanning, and admin review +-- Uses raw string values (not foreign keys) to preserve scan history even if extension is deleted +CREATE TABLE extension_scan ( + id BIGINT NOT NULL PRIMARY KEY DEFAULT nextval('extension_scan_seq'), + + -- Raw metadata about what was scanned (preserved even if extension deleted) + namespace_name VARCHAR(255) NOT NULL, + extension_name VARCHAR(255) NOT NULL, + extension_version VARCHAR(100) NOT NULL, + target_platform VARCHAR(255) NOT NULL, + extension_display_name VARCHAR(255), + universal_target_platform BOOLEAN NOT NULL DEFAULT FALSE, + + -- Publisher information (user who published the extension) + publisher VARCHAR(255) NOT NULL, + publisher_url VARCHAR(255), + + -- Timestamps for tracking scan lifecycle + started_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP, + + -- Current status of the scan process + status VARCHAR(20) NOT NULL, + + -- Error message if scan encountered an error (null otherwise) + error_message VARCHAR(2048) +); + +-- ============================================================================ +-- EXTENSION_VALIDATION_FAILURE TABLE +-- ============================================================================ + +-- Validation failures table +-- Records why an extension failed fast-fail validation checks +CREATE TABLE extension_validation_failure ( + id BIGINT NOT NULL PRIMARY KEY DEFAULT nextval('extension_validation_failure_seq'), + + -- Foreign key to parent scan + scan_id BIGINT NOT NULL, + + -- Type of validation check that failed + validation_type VARCHAR(100) NOT NULL, + + -- Name of the specific validation rule that failed + rule_name VARCHAR(255) NOT NULL, + + -- Detailed explanation of why the validation failed + validation_failure_reason VARCHAR(1024) NOT NULL, + + -- Whether this failure was enforced at the time it was detected. + enforced BOOLEAN NOT NULL DEFAULT TRUE, + + -- Timestamp when the validation failure was detected + detected_at TIMESTAMP NOT NULL, + + -- Foreign key constraint + CONSTRAINT fk_validation_failure_scan FOREIGN KEY (scan_id) + REFERENCES extension_scan(id) ON DELETE CASCADE +); + +-- ============================================================================ +-- EXTENSION_THREAT TABLE +-- ============================================================================ + +-- Threat detection results from security scanners +-- Each row represents one file flagged by one scanner +CREATE TABLE extension_threat ( + id BIGINT NOT NULL PRIMARY KEY DEFAULT nextval('extension_threat_seq'), + + -- Foreign key to parent scan + scan_id BIGINT NOT NULL, + + -- File information + file_name VARCHAR(1024) NOT NULL, + file_hash VARCHAR(128) NOT NULL, + file_extension VARCHAR(50), + + -- Scanner information + scanner_type VARCHAR(100) NOT NULL, + rule_name VARCHAR(255) NOT NULL, + reason VARCHAR(2048), + severity VARCHAR(50), + + -- Whether this threat is enforced + enforced BOOLEAN NOT NULL DEFAULT TRUE, + + -- Timestamp when threat was detected + detected_at TIMESTAMP NOT NULL, + + -- Foreign key constraint + CONSTRAINT fk_threat_scan FOREIGN KEY (scan_id) + REFERENCES extension_scan(id) ON DELETE CASCADE +); + +-- ============================================================================ +-- ADMIN_SCAN_DECISION TABLE +-- ============================================================================ + +-- Admin decisions on quarantined scans (allow/block) +-- Only one decision per scan +CREATE TABLE admin_scan_decision ( + id BIGINT NOT NULL PRIMARY KEY DEFAULT nextval('admin_scan_decision_seq'), + + -- Foreign key to parent scan (unique - one decision per scan) + scan_id BIGINT NOT NULL UNIQUE, + + -- Decision: ALLOWED or BLOCKED + decision VARCHAR(20) NOT NULL, + + -- Admin who made the decision (foreign key to user_data) + decided_by_id BIGINT NOT NULL, + + -- When the decision was made + decided_at TIMESTAMP NOT NULL, + + -- Foreign key constraints + CONSTRAINT fk_decision_scan FOREIGN KEY (scan_id) + REFERENCES extension_scan(id) ON DELETE CASCADE, + CONSTRAINT fk_admin_scan_decision_user FOREIGN KEY (decided_by_id) + REFERENCES user_data(id), + + -- Ensure valid decision values + CONSTRAINT chk_decision_value CHECK (decision IN ('ALLOWED', 'BLOCKED')) +); + +-- ============================================================================ +-- FILE_DECISION TABLE +-- ============================================================================ + +-- Allow list / block list for individual files (by hash) +-- Used to approve or block specific file content for all future extension scans +CREATE TABLE file_decision ( + id BIGINT NOT NULL PRIMARY KEY DEFAULT nextval('file_decision_seq'), + + -- File identification + file_hash VARCHAR(128) NOT NULL UNIQUE, + file_name VARCHAR(1024), + file_type VARCHAR(50), + + -- Decision: ALLOWED or BLOCKED + decision VARCHAR(20) NOT NULL, + + -- Admin who made the decision (foreign key to user_data) + decided_by_id BIGINT NOT NULL, + + -- When the decision was made + decided_at TIMESTAMP NOT NULL, + + -- Context information + -- These capture the extension where the file was first encountered + display_name VARCHAR(255), + namespace_name VARCHAR(255), + extension_name VARCHAR(255), + publisher VARCHAR(255), + version VARCHAR(100), + + -- Optional link to the scan that triggered this decision + scan_id BIGINT, + + -- Foreign key constraints + CONSTRAINT fk_file_decision_scan FOREIGN KEY (scan_id) + REFERENCES extension_scan(id) ON DELETE SET NULL, + CONSTRAINT fk_file_decision_user FOREIGN KEY (decided_by_id) + REFERENCES user_data(id), + + -- Ensure valid decision values + CONSTRAINT chk_file_decision_value CHECK (decision IN ('ALLOWED', 'BLOCKED')) +); + +-- ============================================================================ +-- INDEXES +-- ============================================================================ + +-- Indexes for extension_scan +CREATE INDEX idx_extension_scan_version ON extension_scan(namespace_name, extension_name, extension_version, target_platform); +CREATE INDEX idx_extension_scan_status ON extension_scan(status); +CREATE INDEX idx_extension_scan_completed_at ON extension_scan(completed_at); +CREATE INDEX idx_extension_scan_started_at ON extension_scan(started_at DESC); + +-- Indexes for extension_validation_failure +CREATE INDEX idx_validation_failure_scan ON extension_validation_failure(scan_id); +CREATE INDEX idx_validation_failure_validation_type ON extension_validation_failure(validation_type); +CREATE INDEX idx_validation_failure_detected_at ON extension_validation_failure(detected_at); + +-- Indexes for extension_threat +CREATE INDEX idx_threat_scan ON extension_threat(scan_id); +CREATE INDEX idx_threat_scanner_type ON extension_threat(scanner_type); +CREATE INDEX idx_threat_file_hash ON extension_threat(file_hash); +CREATE INDEX idx_threat_detected_at ON extension_threat(detected_at); + +-- Indexes for admin_scan_decision +CREATE INDEX idx_scan_decision_decided_at ON admin_scan_decision(decided_at); +CREATE INDEX idx_scan_decision_decision ON admin_scan_decision(decision); +CREATE INDEX idx_scan_decision_decided_by ON admin_scan_decision(decided_by_id); + +-- Indexes for file_decision +CREATE INDEX idx_file_decision_decided_at ON file_decision(decided_at); +CREATE INDEX idx_file_decision_decision ON file_decision(decision); +CREATE INDEX idx_file_decision_namespace ON file_decision(namespace_name); +CREATE INDEX idx_file_decision_publisher ON file_decision(publisher); +CREATE INDEX idx_file_decision_decided_by ON file_decision(decided_by_id); \ No newline at end of file diff --git a/server/src/main/resources/scanning/secret-scanning-custom-rules.yaml b/server/src/main/resources/scanning/secret-scanning-custom-rules.yaml index 49787432b..6778da687 100644 --- a/server/src/main/resources/scanning/secret-scanning-custom-rules.yaml +++ b/server/src/main/resources/scanning/secret-scanning-custom-rules.yaml @@ -31,6 +31,7 @@ allowlist: - "sample" - "secret" - "some" + - "standard" - "test" - "your" - "true" diff --git a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java index 8a04a6684..b8a5abf85 100644 --- a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java @@ -27,8 +27,8 @@ import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; import org.eclipse.openvsx.publish.PublishExtensionVersionService; import org.eclipse.openvsx.repositories.RepositoryService; -import org.eclipse.openvsx.scanning.SecretScanResult; -import org.eclipse.openvsx.scanning.SecretScanningService; +import org.eclipse.openvsx.scanning.ExtensionScanPersistenceService; +import org.eclipse.openvsx.scanning.ExtensionScanService; import org.eclipse.openvsx.search.*; import org.eclipse.openvsx.security.OAuth2AttributesConfig; import org.eclipse.openvsx.security.OAuth2UserServices; @@ -39,7 +39,6 @@ import org.eclipse.openvsx.util.VersionAlias; import org.eclipse.openvsx.util.VersionService; import org.jobrunr.scheduling.JobRequestScheduler; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -91,7 +90,7 @@ AzureBlobStorageService.class, AwsStorageService.class, VSCodeIdService.class, DownloadCountService.class, CacheService.class, EclipseService.class, PublishExtensionVersionService.class, SimpleMeterRegistry.class, JobRequestScheduler.class, ExtensionControlService.class, FileCacheDurationConfig.class, CdnServiceConfig.class, - SecretScanningService.class + ExtensionScanPersistenceService.class }) class RegistryAPITest { @@ -110,6 +109,9 @@ class RegistryAPITest { @MockitoBean EntityManager entityManager; + @MockitoBean + ExtensionScanService extensionScanService; + @Autowired MockMvc mockMvc; @@ -2607,9 +2609,17 @@ ExtensionService extensionService( CacheService cache, PublishExtensionVersionHandler publishHandler, JobRequestScheduler scheduler, - SecretScanningService secretScanningService + ExtensionScanService extensionScanService ) { - return new ExtensionService(entityManager, repositories, search, cache, publishHandler, scheduler, secretScanningService); + return new ExtensionService( + entityManager, + repositories, + search, + cache, + publishHandler, + scheduler, + extensionScanService + ); } @Bean @@ -2693,7 +2703,7 @@ PublishExtensionVersionHandler publishExtensionVersionHandler( UserService users, ExtensionValidator validator, ExtensionControlService extensionControl, - SimilarityCheckService similarityCheckService + ExtensionScanService extensionScanService ) { return new PublishExtensionVersionHandler( service, @@ -2704,7 +2714,7 @@ PublishExtensionVersionHandler publishExtensionVersionHandler( users, validator, extensionControl, - similarityCheckService + extensionScanService ); } } diff --git a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java index 2ce99be5f..c1168ab4f 100644 --- a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java @@ -22,7 +22,7 @@ import org.eclipse.openvsx.publish.ExtensionVersionIntegrityService; import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; import org.eclipse.openvsx.repositories.RepositoryService; -import org.eclipse.openvsx.scanning.SecretScanningService; +import org.eclipse.openvsx.scanning.ExtensionScanService; import org.eclipse.openvsx.search.SearchUtilService; import org.eclipse.openvsx.search.SimilarityCheckService; import org.eclipse.openvsx.search.SimilarityConfig; @@ -72,7 +72,7 @@ @MockitoBean(types = { EclipseService.class, ClientRegistrationRepository.class, StorageUtilService.class, CacheService.class, ExtensionValidator.class, SimpleMeterRegistry.class, SearchUtilService.class, PublishExtensionVersionHandler.class, - JobRequestScheduler.class, VersionService.class, ExtensionVersionIntegrityService.class, SecretScanningService.class + JobRequestScheduler.class, VersionService.class, ExtensionVersionIntegrityService.class, ExtensionScanService.class }) class UserAPITest { @@ -867,9 +867,17 @@ ExtensionService extensionService( CacheService cache, PublishExtensionVersionHandler publishHandler, JobRequestScheduler scheduler, - SecretScanningService secretScanningService + ExtensionScanService extensionScanService ) { - return new ExtensionService(entityManager, repositories, search, cache, publishHandler, scheduler, secretScanningService); + return new ExtensionService( + entityManager, + repositories, + search, + cache, + publishHandler, + scheduler, + extensionScanService + ); } } } \ No newline at end of file diff --git a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java index ffeafd393..9304970a8 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java @@ -25,7 +25,7 @@ import org.eclipse.openvsx.publish.ExtensionVersionIntegrityService; import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; import org.eclipse.openvsx.repositories.RepositoryService; -import org.eclipse.openvsx.scanning.SecretScanningService; +import org.eclipse.openvsx.scanning.ExtensionScanService; import org.eclipse.openvsx.search.SearchUtilService; import org.eclipse.openvsx.search.SimilarityCheckService; import org.eclipse.openvsx.search.SimilarityConfig; @@ -80,7 +80,7 @@ AzureBlobStorageService.class, AwsStorageService.class, VSCodeIdService.class, DownloadCountService.class, CacheService.class, PublishExtensionVersionHandler.class, SearchUtilService.class, EclipseService.class, SimpleMeterRegistry.class, FileCacheDurationConfig.class, MailService.class, CdnServiceConfig.class, - SecretScanningService.class + ExtensionScanService.class }) class AdminAPITest { @@ -1458,9 +1458,17 @@ ExtensionService extensionService( CacheService cache, PublishExtensionVersionHandler publishHandler, JobRequestScheduler scheduler, - SecretScanningService secretScanningService + ExtensionScanService extensionScanService ) { - return new ExtensionService(entityManager, repositories, search, cache, publishHandler, scheduler, secretScanningService); + return new ExtensionService( + entityManager, + repositories, + search, + cache, + publishHandler, + scheduler, + extensionScanService + ); } @Bean diff --git a/server/src/test/java/org/eclipse/openvsx/admin/ScanAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/ScanAPITest.java new file mode 100644 index 000000000..3d40ceace --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/admin/ScanAPITest.java @@ -0,0 +1,406 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.admin; + +import org.eclipse.openvsx.entities.ExtensionScan; +import org.eclipse.openvsx.entities.ExtensionValidationFailure; +import org.eclipse.openvsx.entities.ExtensionVersion; +import org.eclipse.openvsx.entities.ScanStatus; +import io.micrometer.core.instrument.MeterRegistry; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.storage.StorageUtilService; +import org.eclipse.openvsx.util.ErrorResultException; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.util.Streamable; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(ScanAPI.class) +@AutoConfigureMockMvc(addFilters = false) +class ScanAPITest { + + @Autowired + MockMvc mockMvc; + + @MockitoBean + RepositoryService repositories; + + @MockitoBean + AdminService admins; + + @MockitoBean + StorageUtilService storageUtil; + + @MockitoBean + MeterRegistry meterRegistry; + + @Test + void getScans_filters_sorting_and_pagination_are_applied() throws Exception { + // Always allow the request to pass the admin gate in this test setup. + Mockito.when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + + // Build scan with display name for the sorted/filtered result. + var scanC = TestData.scan(3, "gamma", "third", "2.0.0", "alpha-team", ScanStatus.VALIDATING, LocalDateTime.of(2024, 12, 3, 10, 0)); + scanC.setExtensionDisplayName("Alpha Utility"); + + // Mock the DB-level filtered/paginated query to return just the expected result. + // The DB does filtering and pagination, so tests now verify correct parameters are passed. + Mockito.when(repositories.findScansFullyFiltered( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any() + )).thenReturn(new PageImpl<>(List.of(scanC), org.springframework.data.domain.PageRequest.of(0, 1), 2)); + + Mockito.when(repositories.findValidationFailures(Mockito.any())).thenReturn(Streamable.empty()); + Mockito.when(repositories.findExtensionThreats(Mockito.any())).thenReturn(Streamable.empty()); + Mockito.when(storageUtil.getFileUrls(Mockito.anyList(), Mockito.anyString(), Mockito.any(), Mockito.any())).thenReturn(Map.of()); + + // Provide display name from linked version + Mockito.when(repositories.findVersion("2.0.0", "universal", "third", "gamma")).thenReturn(TestData.version(12, "Alpha Utility")); + + mockMvc.perform(get("/admin/api/scans") + .param("status", "VALIDATING") + .param("publisher", "alpha") + .param("namespace", "a") + .param("name", "a") + .param("size", "1") + .param("offset", "0") + .param("sortBy", "displayName") + .param("sortOrder", "asc") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalSize").value(2)) + .andExpect(jsonPath("$.offset").value(0)) + .andExpect(jsonPath("$.scans.length()").value(1)) + .andExpect(jsonPath("$.scans[0].displayName").value("Alpha Utility")) + .andExpect(jsonPath("$.scans[0].extensionName").value("third")) + .andExpect(jsonPath("$.scans[0].targetPlatform").value("universal")) + .andExpect(jsonPath("$.scans[0].universalTargetPlatform").value(true)) + .andExpect(jsonPath("$.scans[0].status").value("VALIDATING")); + } + + @Test + void getScans_namespace_partial_match_is_applied() throws Exception { + Mockito.when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + + var scanA = TestData.scan(1, "alpha-ns", "ext-a", "1.0.0", "pub", ScanStatus.PASSED, LocalDateTime.of(2024, 12, 1, 10, 0)); + + // DB-level filtering returns only the matching scan + Mockito.when(repositories.findScansFullyFiltered( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any() + )).thenReturn(new PageImpl<>(List.of(scanA))); + + Mockito.when(repositories.findValidationFailures(Mockito.any())).thenReturn(Streamable.empty()); + Mockito.when(repositories.findExtensionThreats(Mockito.any())).thenReturn(Streamable.empty()); + Mockito.when(repositories.findVersion(Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyString())).thenReturn(null); + Mockito.when(storageUtil.getFileUrls(Mockito.anyList(), Mockito.anyString(), Mockito.any(), Mockito.any())).thenReturn(Map.of()); + + mockMvc.perform(get("/admin/api/scans") + .param("namespace", "alp") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalSize").value(1)) + .andExpect(jsonPath("$.scans.length()").value(1)) + .andExpect(jsonPath("$.scans[0].namespace").value("alpha-ns")); + } + + @Test + void getScans_name_matches_extensionName_and_displayName_partial() throws Exception { + Mockito.when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + + var scanA = TestData.scan(1, "alpha-ns", "alpha-one", "1.0.0", "pub", ScanStatus.PASSED, LocalDateTime.of(2024, 12, 1, 10, 0)); + scanA.setExtensionDisplayName("Zebra Toolkit"); + var scanB = TestData.scan(2, "beta-ns", "beta-two", "1.0.0", "pub", ScanStatus.PASSED, LocalDateTime.of(2024, 12, 1, 10, 0)); + scanB.setExtensionDisplayName("Something Else"); + + Mockito.when(repositories.findValidationFailures(Mockito.any())).thenReturn(Streamable.empty()); + Mockito.when(repositories.findExtensionThreats(Mockito.any())).thenReturn(Streamable.empty()); + Mockito.when(storageUtil.getFileUrls(Mockito.anyList(), Mockito.anyString(), Mockito.any(), Mockito.any())).thenReturn(Map.of()); + + Mockito.when(repositories.findVersion("1.0.0", "universal", "alpha-one", "alpha-ns")).thenReturn(TestData.version(10, "Zebra Toolkit")); + Mockito.when(repositories.findVersion("1.0.0", "universal", "beta-two", "beta-ns")).thenReturn(TestData.version(11, "Something Else")); + + // First request: DB returns scanA which matches displayName "Toolkit" + Mockito.when(repositories.findScansFullyFiltered( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any() + )).thenReturn(new PageImpl<>(List.of(scanA))); + + // Match by displayName partial (case-insensitive) + mockMvc.perform(get("/admin/api/scans") + .param("name", "tool") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalSize").value(1)) + .andExpect(jsonPath("$.scans[0].extensionName").value("alpha-one")); + + // Second request: DB returns scanB which matches extensionName "beta" + Mockito.when(repositories.findScansFullyFiltered( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any() + )).thenReturn(new PageImpl<>(List.of(scanB))); + + // Match by extensionName partial + mockMvc.perform(get("/admin/api/scans") + .param("name", "bet") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalSize").value(1)) + .andExpect(jsonPath("$.scans[0].extensionName").value("beta-two")); + } + + @Test + void getScans_status_supports_comma_separated_values() throws Exception { + Mockito.when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + + var scanPassed = TestData.scan(2, "ns", "ext-passed", "1.0.0", "pub", ScanStatus.PASSED, LocalDateTime.of(2024, 12, 1, 10, 0)); + var scanErrored = TestData.scan(3, "ns", "ext-error", "1.0.0", "pub", ScanStatus.ERRORED, LocalDateTime.of(2024, 12, 1, 10, 0)); + + // DB returns only PASSED and ERRORED scans (filtered by status) + Mockito.when(repositories.findScansFullyFiltered( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any() + )).thenReturn(new PageImpl<>(List.of(scanPassed, scanErrored))); + + Mockito.when(repositories.findValidationFailures(Mockito.any())).thenReturn(Streamable.empty()); + Mockito.when(repositories.findExtensionThreats(Mockito.any())).thenReturn(Streamable.empty()); + Mockito.when(repositories.findVersion(Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyString())).thenReturn(null); + Mockito.when(storageUtil.getFileUrls(Mockito.anyList(), Mockito.anyString(), Mockito.any(), Mockito.any())).thenReturn(Map.of()); + + // explode=false behavior: status=PASSED,ERROR should be parsed into a list of two values. + mockMvc.perform(get("/admin/api/scans") + .param("status", "PASSED,ERROR") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalSize").value(2)) + .andExpect(jsonPath("$.scans.length()").value(2)); + } + + @Test + void getScans_checkType_supports_comma_separated_values() throws Exception { + Mockito.when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + + var scanA = TestData.scan(1, "ns", "ext-a", "1.0.0", "pub", ScanStatus.REJECTED, LocalDateTime.of(2024, 12, 1, 10, 0)); + + // DB returns only scanA (filtered by checkType) + Mockito.when(repositories.findScansFullyFiltered( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any() + )).thenReturn(new PageImpl<>(List.of(scanA))); + + Mockito.when(repositories.findVersion(Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyString())).thenReturn(null); + Mockito.when(repositories.findExtensionThreats(Mockito.any())).thenReturn(Streamable.empty()); + Mockito.when(storageUtil.getFileUrls(Mockito.anyList(), Mockito.anyString(), Mockito.any(), Mockito.any())).thenReturn(Map.of()); + + // scanA has a validation failure with checkType NAME_SQUATTING + Mockito.when(repositories.findValidationFailures(Mockito.any())).thenAnswer(invocation -> { + var scan = (ExtensionScan) invocation.getArgument(0); + if (scan.getId() == 1) { + var failure = ExtensionValidationFailure.create("NAME_SQUATTING", "any-name", "reason"); + failure.setEnforced(true); + failure.setScan(scanA); + return Streamable.of(failure); + } + return Streamable.empty(); + }); + + // Validates CSV parsing: "BLOCKLIST,NAME SQUATTING" -> ["BLOCKLIST", "NAME SQUATTING"] + mockMvc.perform(get("/admin/api/scans") + .param("validationType", "BLOCKLIST,NAME SQUATTING") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalSize").value(1)) + .andExpect(jsonPath("$.scans.length()").value(1)) + .andExpect(jsonPath("$.scans[0].extensionName").value("ext-a")); + } + + @Test + void getScanFilterOptions_returns_validationTypes() throws Exception { + Mockito.when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + Mockito.when(repositories.findDistinctValidationFailureCheckTypes()).thenReturn(java.util.List.of("NAME_SQUATTING", "BLOCKLIST")); + + mockMvc.perform(get("/admin/api/scans/filterOptions").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.validationTypes.length()").value(2)) + .andExpect(jsonPath("$.validationTypes[0]").value("NAME_SQUATTING")) + .andExpect(jsonPath("$.validationFailureNames").doesNotExist()); + } + + @Test + void getScans_rejects_unknown_sort_field() throws Exception { + Mockito.when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + Mockito.when(repositories.findAllExtensionScans()).thenReturn(Streamable.empty()); + + mockMvc.perform(get("/admin/api/scans") + .param("sortBy", "unknownField") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.error").value("Unsupported sortBy value: unknownField")); + } + + @Test + void getScanCounts_returns_status_counts_and_zero_decisions() throws Exception { + Mockito.when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + Mockito.when(repositories.countExtensionScansByStatus(ScanStatus.STARTED)).thenReturn(1L); + Mockito.when(repositories.countExtensionScansByStatus(ScanStatus.VALIDATING)).thenReturn(2L); + Mockito.when(repositories.countExtensionScansByStatus(ScanStatus.SCANNING)).thenReturn(3L); + Mockito.when(repositories.countExtensionScansByStatus(ScanStatus.PASSED)).thenReturn(4L); + Mockito.when(repositories.countExtensionScansByStatus(ScanStatus.QUARANTINED)).thenReturn(5L); + Mockito.when(repositories.countExtensionScansByStatus(ScanStatus.REJECTED)).thenReturn(6L); + Mockito.when(repositories.countExtensionScansByStatus(ScanStatus.ERRORED)).thenReturn(7L); + + // Default behavior (no filters): uses the fast count-by-status repository calls. + mockMvc.perform(get("/admin/api/scans/counts").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.STARTED").value(1)) + .andExpect(jsonPath("$.VALIDATING").value(2)) + .andExpect(jsonPath("$.SCANNING").value(3)) + .andExpect(jsonPath("$.PASSED").value(4)) + .andExpect(jsonPath("$.QUARANTINED").value(5)) + .andExpect(jsonPath("$.AUTO_REJECTED").value(6)) + .andExpect(jsonPath("$.ERROR").value(7)) + .andExpect(jsonPath("$.ALLOWED").value(0)) + .andExpect(jsonPath("$.BLOCKED").value(0)) + .andExpect(jsonPath("$.NEEDS_REVIEW").value(5)) // QUARANTINED(5) - ALLOWED(0) - BLOCKED(0) + .andExpect(jsonPath("$.started").doesNotExist()) + .andExpect(jsonPath("$.error").doesNotExist()); + } + + @Test + void getScanCounts_supports_enforcement_filtering() throws Exception { + Mockito.when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + + // DB-level enforcement filtering: mock counts for each status with enforcement + // When enforcement filter is applied, the code uses countScansForStatistics + // First request: enforced=true -> returns 1 for REJECTED + Mockito.when(repositories.countScansForStatistics( + Mockito.eq(ScanStatus.REJECTED), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.eq(true) + )).thenReturn(1L); + Mockito.when(repositories.countScansForStatistics( + Mockito.eq(ScanStatus.REJECTED), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.eq(false) + )).thenReturn(1L); + + // Other statuses return 0 when enforcement filter is applied + Mockito.when(repositories.countScansForStatistics( + Mockito.argThat(s -> s != ScanStatus.REJECTED), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.anyBoolean() + )).thenReturn(0L); + + mockMvc.perform(get("/admin/api/scans/counts") + .param("enforcement", "enforced") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.AUTO_REJECTED").value(1)); + + mockMvc.perform(get("/admin/api/scans/counts") + .param("enforcement", "notEnforced") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.AUTO_REJECTED").value(1)); + } + + @Test + void getScans_returns_displayName_from_scan_when_version_missing() throws Exception { + Mockito.when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); + + var scan = TestData.scan(99, "ns", "ext", "0.0.1", "pub", ScanStatus.REJECTED, LocalDateTime.of(2024, 12, 4, 10, 0)); + scan.setExtensionDisplayName("Manifest Display"); + + Mockito.when(repositories.findScansFullyFiltered( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any() + )).thenReturn(new PageImpl<>(List.of(scan))); + + Mockito.when(repositories.findVersion("0.0.1", "universal", "ext", "ns")).thenReturn(null); + Mockito.when(repositories.findValidationFailures(Mockito.any())).thenReturn(Streamable.empty()); + Mockito.when(repositories.findExtensionThreats(Mockito.any())).thenReturn(Streamable.empty()); + Mockito.when(storageUtil.getFileUrls(Mockito.anyList(), Mockito.anyString(), Mockito.any(), Mockito.any())).thenReturn(Map.of()); + + mockMvc.perform(get("/admin/api/scans").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.scans[0].displayName").value("Manifest Display")) + .andExpect(jsonPath("$.scans[0].extensionName").value("ext")) + .andExpect(jsonPath("$.scans[0].targetPlatform").value("universal")) + .andExpect(jsonPath("$.scans[0].universalTargetPlatform").value(true)); + } + + @Test + void getScanCounts_requires_admin() throws Exception { + Mockito.when(admins.checkAdminUser()).thenThrow(new ErrorResultException("Administration role is required.", HttpStatus.FORBIDDEN)); + + mockMvc.perform(get("/admin/api/scans/counts").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + void getScans_requires_admin() throws Exception { + Mockito.when(admins.checkAdminUser()).thenThrow(new ErrorResultException("Administration role is required.", HttpStatus.FORBIDDEN)); + + mockMvc.perform(get("/admin/api/scans").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + } + + private static class TestData { + + static ExtensionScan scan(long id, String namespace, String name, String version, String publisher, ScanStatus status, LocalDateTime startedAt) { + var scan = new ExtensionScan(); + scan.setId(id); + scan.setNamespaceName(namespace); + scan.setExtensionName(name); + scan.setExtensionVersion(version); + scan.setTargetPlatform("universal"); + scan.setUniversalTargetPlatform(true); + scan.setPublisher(publisher); + scan.setStartedAt(startedAt); + scan.setStatus(status); + return scan; + } + + static ExtensionVersion version(long id, String displayName) { + var version = new ExtensionVersion(); + version.setId(id); + version.setDisplayName(displayName); + return version; + } + + static org.eclipse.openvsx.entities.UserData adminUser() { + var user = new org.eclipse.openvsx.entities.UserData(); + user.setRole(org.eclipse.openvsx.entities.UserData.ROLE_ADMIN); + return user; + } + } +} + diff --git a/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java b/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java index 29eb0b813..a9795e3e9 100644 --- a/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java +++ b/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java @@ -22,7 +22,7 @@ import org.eclipse.openvsx.entities.*; import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; import org.eclipse.openvsx.repositories.RepositoryService; -import org.eclipse.openvsx.scanning.SecretScanningService; +import org.eclipse.openvsx.scanning.ExtensionScanService; import org.eclipse.openvsx.search.SearchUtilService; import org.eclipse.openvsx.storage.*; import org.eclipse.openvsx.storage.log.DownloadCountService; @@ -60,7 +60,7 @@ EntityManager.class, SearchUtilService.class, GoogleCloudStorageService.class, AzureBlobStorageService.class, AwsStorageService.class, VSCodeIdService.class, DownloadCountService.class, CacheService.class, UserService.class, PublishExtensionVersionHandler.class, SimpleMeterRegistry.class, FileCacheDurationConfig.class, - JobRequestScheduler.class, CdnServiceConfig.class, SecretScanningService.class + JobRequestScheduler.class, CdnServiceConfig.class, ExtensionScanService.class }) class EclipseServiceTest { @@ -411,9 +411,17 @@ ExtensionService extensionService( CacheService cache, PublishExtensionVersionHandler publishHandler, JobRequestScheduler scheduler, - SecretScanningService secretScanningService + ExtensionScanService extensionScanService ) { - return new ExtensionService(entityManager, repositories, search, cache, publishHandler, scheduler, secretScanningService); + return new ExtensionService( + entityManager, + repositories, + search, + cache, + publishHandler, + scheduler, + extensionScanService + ); } @Bean diff --git a/server/src/test/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandlerTest.java b/server/src/test/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandlerTest.java index 0a320ed9f..16dcaac4a 100644 --- a/server/src/test/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandlerTest.java +++ b/server/src/test/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandlerTest.java @@ -19,12 +19,12 @@ import org.eclipse.openvsx.entities.Extension; import org.eclipse.openvsx.entities.ExtensionVersion; import org.eclipse.openvsx.entities.Namespace; -import org.eclipse.openvsx.entities.NamespaceMembership; import org.eclipse.openvsx.entities.PersonalAccessToken; import org.eclipse.openvsx.extension_control.ExtensionControlService; import org.eclipse.openvsx.repositories.RepositoryService; -import org.eclipse.openvsx.search.SimilarityCheckService; +import org.eclipse.openvsx.scanning.ExtensionScanService; import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.TempFile; import org.jobrunr.scheduling.JobRequestScheduler; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -32,7 +32,6 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.util.Streamable; import java.time.LocalDateTime; import java.util.Collections; @@ -41,7 +40,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -73,13 +71,12 @@ class PublishExtensionVersionHandlerTest { ExtensionControlService extensionControl; @Mock - SimilarityCheckService similarityCheckService; + ExtensionScanService scanService; private PublishExtensionVersionHandler handler; @BeforeEach void setUp() throws Exception { - // Keep defaults permissive so tests focus on similarity behaviour. handler = new PublishExtensionVersionHandler( publishService, integrityService, @@ -89,96 +86,18 @@ void setUp() throws Exception { users, validator, extensionControl, - similarityCheckService + scanService ); - when(extensionControl.getMaliciousExtensionIds()).thenReturn(Collections.emptyList()); + + // Lenient: not all tests need this mock + org.mockito.Mockito.lenient() + .when(extensionControl.getMaliciousExtensionIds()) + .thenReturn(Collections.emptyList()); } @Test - void shouldFailPublishingWhenSimilarExtensionAlreadyExists() { - // Build minimal processor metadata. - var processor = org.mockito.Mockito.mock(ExtensionProcessor.class); - when(processor.getNamespace()).thenReturn("publisher"); - when(processor.getExtensionName()).thenReturn("demo"); - when(processor.getVersion()).thenReturn("1.0.0"); - - var metadata = new ExtensionVersion(); - metadata.setDisplayName("Demo Extension"); - metadata.setVersion("1.0.0"); - metadata.setTargetPlatform("any"); - when(processor.getMetadata()).thenReturn(metadata); - - var user = new org.eclipse.openvsx.entities.UserData(); - var token = new PersonalAccessToken(); - token.setUser(user); - - var namespace = new Namespace(); - namespace.setName("publisher"); - when(repositories.findNamespace("publisher")).thenReturn(namespace); - when(users.hasPublishPermission(user, namespace)).thenReturn(true); - when(validator.validateExtensionVersion("1.0.0")).thenReturn(Optional.empty()); - when(validator.validateExtensionName("demo")).thenReturn(Optional.empty()); - - var similarExtension = new Extension(); - similarExtension.setNamespace(buildNamespace("other")); - similarExtension.setName("demo-other"); - when(similarityCheckService.isEnabled()).thenReturn(true); - when(similarityCheckService.findSimilarExtensionsForPublishing("demo", "publisher", "Demo Extension", user)) - .thenReturn(List.of(similarExtension)); - - var similarLatest = new ExtensionVersion(); - similarLatest.setDisplayName("Existing Demo"); - when(repositories.findLatestVersion(similarExtension, null, false, true)).thenReturn(similarLatest); - - assertThatThrownBy(() -> handler.createExtensionVersion(processor, token, LocalDateTime.now(), true)) - .isInstanceOf(ErrorResultException.class) - .hasMessageContaining("too similar to existing extension"); - - // Persist should never happen because we bail out early on similarity. - verify(entityManager, never()).persist(metadata); - verify(similarityCheckService).findSimilarExtensionsForPublishing("demo", "publisher", "Demo Extension", user); - } - - @Test - void shouldExcludeOwnedNamespacesFromSimilarityCheck() { - // Ensure owner namespaces are excluded when configured. - var processor = org.mockito.Mockito.mock(ExtensionProcessor.class); - when(processor.getNamespace()).thenReturn("publisher"); - when(processor.getExtensionName()).thenReturn("demo"); - when(processor.getVersion()).thenReturn("1.0.1"); - when(processor.getExtensionDependencies()).thenReturn(List.of()); - when(processor.getBundledExtensions()).thenReturn(List.of()); - - var metadata = new ExtensionVersion(); - metadata.setDisplayName("Demo Next"); - metadata.setVersion("1.0.1"); - metadata.setTargetPlatform("any"); - when(processor.getMetadata()).thenReturn(metadata); - - var namespace = buildNamespace("publisher"); - var ownedNamespace = buildNamespace("owned-ns"); - var user = new org.eclipse.openvsx.entities.UserData(); - var token = new PersonalAccessToken(); - token.setUser(user); - - when(repositories.findNamespace("publisher")).thenReturn(namespace); - when(users.hasPublishPermission(user, namespace)).thenReturn(true); - when(validator.validateExtensionVersion("1.0.1")).thenReturn(Optional.empty()); - when(validator.validateExtensionName("demo")).thenReturn(Optional.empty()); - when(validator.validateMetadata(metadata)).thenReturn(List.of()); - when(similarityCheckService.isEnabled()).thenReturn(true); - when(similarityCheckService.findSimilarExtensionsForPublishing("demo", "publisher", "Demo Next", user)) - .thenReturn(List.of()); - when(repositories.findExtension("demo", namespace)).thenReturn(null); - - handler.createExtensionVersion(processor, token, LocalDateTime.now(), false); - - verify(similarityCheckService).findSimilarExtensionsForPublishing("demo", "publisher", "Demo Next", user); - } - - @Test - void shouldCreateExtensionWhenSimilarityFindsNoConflicts() { - // Happy path: similarity passes and entities get persisted and linked. + void shouldCreateExtensionWhenNamespaceExists() { + // Happy path: extension version gets persisted. var processor = org.mockito.Mockito.mock(ExtensionProcessor.class); when(processor.getNamespace()).thenReturn("publisher"); when(processor.getExtensionName()).thenReturn("demo"); @@ -202,56 +121,35 @@ void shouldCreateExtensionWhenSimilarityFindsNoConflicts() { when(validator.validateExtensionVersion("2.0.0")).thenReturn(Optional.empty()); when(validator.validateExtensionName("demo")).thenReturn(Optional.empty()); when(validator.validateMetadata(metadata)).thenReturn(List.of()); - when(similarityCheckService.isEnabled()).thenReturn(true); - when(similarityCheckService.findSimilarExtensionsForPublishing("demo", "publisher", "Demo OK", user)) - .thenReturn(List.of()); when(repositories.findExtension("demo", namespace)).thenReturn(null); - var capturedNamespace = ArgumentCaptor.forClass(Extension.class); + var capturedExtension = ArgumentCaptor.forClass(Extension.class); var result = handler.createExtensionVersion(processor, token, LocalDateTime.now(), false); - verify(entityManager).persist(capturedNamespace.capture()); + verify(entityManager).persist(capturedExtension.capture()); verify(entityManager).persist(metadata); assertThat(result).isSameAs(metadata); assertThat(result.getPublishedWith()).isEqualTo(token); - assertThat(result.getExtension()).isSameAs(capturedNamespace.getValue()); + assertThat(result.getExtension()).isSameAs(capturedExtension.getValue()); assertThat(result.getExtension().getNamespace()).isSameAs(namespace); } @Test - void shouldCheckSimilarityForAllExtensions() { - // All extensions should be checked for similarity. + void shouldFailWhenNamespaceDoesNotExist() { + // When namespace doesn't exist, handler should throw an error. var processor = org.mockito.Mockito.mock(ExtensionProcessor.class); - when(processor.getNamespace()).thenReturn("pub"); - when(processor.getExtensionName()).thenReturn("demo"); - when(processor.getVersion()).thenReturn("3.0.0"); - when(processor.getMetadata()).thenReturn(new ExtensionVersion()); + when(processor.getNamespace()).thenReturn("unknown"); var user = new org.eclipse.openvsx.entities.UserData(); var token = new PersonalAccessToken(); token.setUser(user); - var namespace = buildNamespace("pub"); - when(repositories.findNamespace("pub")).thenReturn(namespace); - when(users.hasPublishPermission(user, namespace)).thenReturn(true); - when(validator.validateExtensionVersion("3.0.0")).thenReturn(Optional.empty()); - when(validator.validateExtensionName("demo")).thenReturn(Optional.empty()); - when(validator.validateMetadata(processor.getMetadata())).thenReturn(List.of()); - when(similarityCheckService.isEnabled()).thenReturn(true); - when(similarityCheckService.findSimilarExtensionsForPublishing("demo", "pub", null, user)).thenReturn(List.of()); - when(repositories.findExtension("demo", namespace)).thenReturn(null); - - handler.createExtensionVersion(processor, token, LocalDateTime.now(), false); + when(repositories.findNamespace("unknown")).thenReturn(null); - verify(similarityCheckService).findSimilarExtensionsForPublishing("demo", "pub", null, user); - } - - private NamespaceMembership buildOwnerMembership(Namespace namespace) { - var membership = new NamespaceMembership(); - membership.setNamespace(namespace); - membership.setRole(NamespaceMembership.ROLE_OWNER); - return membership; + assertThatThrownBy(() -> handler.createExtensionVersion(processor, token, LocalDateTime.now(), false)) + .isInstanceOf(ErrorResultException.class) + .hasMessageContaining("Unknown publisher"); } private Namespace buildNamespace(String name) { diff --git a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java index 52fda9055..00d6f6630 100644 --- a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java +++ b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java @@ -58,6 +58,8 @@ void testExecuteQueries() { // some queries require attached entities: var extension = new Extension(); var namespace = new Namespace(); + namespace.setName("namespaceName"); + extension.setName("extensionName"); extension.setNamespace(namespace); var userData = new UserData(); var extVersion = new ExtensionVersion(); @@ -68,13 +70,50 @@ void testExecuteQueries() { var keyPair = new SignatureKeyPair(); keyPair.setPrivateKey(new byte[0]); keyPair.setPublicKeyText(""); - Stream.of(extension, namespace, userData, extVersion, personalAccessToken, keyPair).forEach(em::persist); + + var scan = new ExtensionScan(); + scan.setNamespaceName(namespace.getName()); + scan.setExtensionName(extension.getName()); + scan.setExtensionVersion(extVersion.getVersion()); + scan.setTargetPlatform(extVersion.getTargetPlatform()); + scan.setPublisher("publisher"); + scan.setPublisherUrl("https://example.com"); + scan.setExtensionVersion(extVersion.getVersion()); + scan.setStartedAt(LocalDateTime.now()); + scan.setStatus(ScanStatus.STARTED); + + var validationFailure = ExtensionValidationFailure.create("NAME_SQUATTING", "validation-name", "reason"); + validationFailure.setEnforced(true); + validationFailure.setScan(scan); + + // Admin scan decision entity for testing + var adminDecision = AdminScanDecision.allowed(scan, userData); + + // File decision entity for testing + var fileDecision = FileDecision.allowed("fileHash", userData); + fileDecision.setScan(scan); + fileDecision.setFileName("file.txt"); + fileDecision.setFileType(".txt"); + fileDecision.setNamespaceName("namespaceName"); + fileDecision.setExtensionName("extensionName"); + fileDecision.setDisplayName("Display Name"); + fileDecision.setPublisher("publisher"); + fileDecision.setVersion("1.0.0"); + + // Extension threat entity for testing + var threat = ExtensionThreat.create("test.js", "threatFileHash", ".js", "testScanner", "test-rule", "Test threat", "high"); + threat.setScan(scan); + + // Persist all entities consistently using EntityManager + Stream.of(namespace, extension, userData, extVersion, personalAccessToken, keyPair, + scan, validationFailure, adminDecision, fileDecision, threat) + .forEach(em::persist); em.flush(); - + var page = PageRequest.ofSize(1); var queryRequest = new QueryRequest(null, null, null, null, null, null, false, null, 1, 0); - // record executed queries + // Record executed queries var methodsToBeCalled = Stream.of(repositories.getClass().getDeclaredMethods()) .filter(m -> Modifier.isPublic(m.getModifiers())) .collect(toList()); @@ -222,7 +261,84 @@ void testExecuteQueries() { () -> repositories.isDeleteAllVersions("namespaceName", "extensionName", Collections.emptyList(), userData), () -> repositories.deactivateAccessTokens(userData), () -> repositories.findSimilarExtensionsByLevenshtein("extensionName", "namespaceName", "displayName", Collections.emptyList(), 0.5, false, 10), - () -> repositories.findSimilarNamespacesByLevenshtein("namespaceName", Collections.emptyList(), 0.5, false, 10) + () -> repositories.findSimilarNamespacesByLevenshtein("namespaceName", Collections.emptyList(), 0.5, false, 10), + () -> repositories.findExtensionScans(extVersion), + () -> repositories.findLatestExtensionScan(extVersion), + () -> repositories.findExtensionScans(extension), + () -> repositories.findExtensionScansByNamespace(namespace.getName()), + () -> repositories.findExtensionScansByStatus(ScanStatus.STARTED), + () -> repositories.findInProgressExtensionScans(), + () -> repositories.countExtensionScansByStatus(ScanStatus.STARTED), + () -> repositories.findExtensionScan(scan.getId()), + () -> repositories.hasExtensionScanWithStatus(extVersion, ScanStatus.STARTED), + () -> repositories.findAllExtensionScans(), + () -> repositories.findValidationFailures(scan), + () -> repositories.findValidationFailuresByType(validationFailure.getCheckType()), + () -> repositories.findValidationFailures(scan, validationFailure.getCheckType()), + () -> repositories.countValidationFailures(scan), + () -> repositories.countValidationFailuresByType(validationFailure.getCheckType()), + () -> repositories.hasValidationFailures(scan), + () -> repositories.hasValidationFailuresOfType(scan, validationFailure.getCheckType()), + () -> repositories.findValidationFailure(validationFailure.getId()), + () -> repositories.findDistinctValidationFailureRuleNames(), + () -> repositories.findDistinctValidationFailureCheckTypes(), + () -> repositories.saveExtensionScan(scan), + () -> repositories.saveValidationFailure(validationFailure), + // DB paging and filtering methods for scan API + () -> repositories.countExtensionScansByStatusAndDateRange(ScanStatus.STARTED, NOW, NOW), + () -> repositories.countExtensionScansByStatusDateRangeAndEnforcement(ScanStatus.STARTED, NOW, NOW, true), + () -> repositories.findScansFiltered(List.of(ScanStatus.STARTED), "namespaceName", "publisher", "extensionName", NOW, NOW, page), + () -> repositories.countScansFiltered(List.of(ScanStatus.STARTED), "namespaceName", "publisher", "extensionName", NOW, NOW), + () -> repositories.findScansFullyFiltered(List.of(ScanStatus.STARTED), "namespaceName", "publisher", "extensionName", NOW, NOW, List.of("checkType"), List.of("scanner"), true, null, page), + () -> repositories.countScansFullyFiltered(List.of(ScanStatus.STARTED), "namespaceName", "publisher", "extensionName", NOW, NOW, List.of("checkType"), List.of("scanner"), true, null), + // Statistics queries with full filter support + () -> repositories.countScansForStatistics(ScanStatus.STARTED, NOW, NOW, List.of("checkType"), List.of("scanner"), true), + () -> repositories.countAdminDecisionsForStatistics("ALLOWED", NOW, NOW, List.of("checkType"), List.of("scanner"), true), + // Admin scan decision methods + () -> repositories.findAdminScanDecision(scan), + () -> repositories.findAdminScanDecision(scan.getId()), + () -> repositories.saveAdminScanDecision(adminDecision), + () -> repositories.deleteAdminScanDecision(adminDecision.getId()), + () -> repositories.hasAdminScanDecisionByScanId(scan.getId()), + () -> repositories.countAdminScanDecisions("ALLOWED"), + // Note: We pass valid LocalDateTime values to avoid PostgreSQL null parameter type issues + () -> repositories.countAdminScanDecisionsByDateRange("ALLOWED", NOW.minusYears(1), NOW.plusYears(1)), + () -> repositories.countAdminScanDecisionsByEnforcement("ALLOWED", true), + () -> repositories.findAdminScanDecisionByScanId(scan.getId()), + // File decision methods + () -> repositories.hasFileDecision("fileHash"), + () -> repositories.findFileDecision(fileDecision.getId()), + () -> repositories.saveFileDecision(fileDecision), + () -> repositories.deleteFileDecision(fileDecision.getId()), + () -> repositories.deleteFileDecisionByHash("fileHash"), + () -> repositories.findFileDecisionsByIds(LONG_LIST), + () -> repositories.findFileDecisionByHash("fileHash"), + () -> repositories.findFileDecisionsFiltered("ALLOWED", "publisher", "namespace", "name", NOW.minusYears(1), NOW.plusYears(1), page), + () -> repositories.countAllFileDecisions(), + // Note: We pass valid LocalDateTime values to avoid PostgreSQL null parameter type issues + () -> repositories.countFileDecisionsByDateRange("ALLOWED", NOW.minusYears(1), NOW.plusYears(1)), + // Extension threat methods + () -> repositories.saveExtensionThreat(threat), + () -> repositories.findExtensionThreat(threat.getId()), + () -> repositories.findExtensionThreats(scan), + () -> repositories.hasExtensionThreats(scan), + () -> repositories.countExtensionThreats(scan), + () -> repositories.findDistinctThreatScannerTypes(), + () -> repositories.findExtensionThreatsByScanId(scan.getId()), + () -> repositories.findExtensionThreatsByFileHash("fileHash"), + () -> repositories.findDistinctThreatRuleNames(), + () -> repositories.findExtensionThreatsByType("testType"), + () -> repositories.findExtensionThreats(scan, "testType"), + () -> repositories.findExtensionThreatsAfter(NOW.minusYears(1)), + () -> repositories.findExtensionThreatsOrdered(scan), + () -> repositories.countExtensionThreatsByType("testType"), + () -> repositories.hasExtensionThreatsOfType(scan, "testType"), + // Additional admin scan decision methods + () -> repositories.hasAdminScanDecision(scan), + // Additional file decision methods + () -> repositories.countFileDecisions("ALLOWED"), + // Extension scan delete method + () -> repositories.deleteExtensionScan(scan) ); // check that we did not miss anything diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/ExtensionScanServiceEnforcementTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/ExtensionScanServiceEnforcementTest.java new file mode 100644 index 000000000..e440d8c47 --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/scanning/ExtensionScanServiceEnforcementTest.java @@ -0,0 +1,279 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import org.eclipse.openvsx.entities.*; +import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.TempFile; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Focused tests for "enforced vs monitor-only" behavior. + * + * Goal: + * - failures are always persisted + * - publishing is only blocked when enforcement is enabled (throws ErrorResultException) + */ +@ExtendWith(MockitoExtension.class) +class ExtensionScanServiceEnforcementTest { + + @Mock ExtensionScanConfig config; + @Mock ExtensionScanner scanner; + @Mock ExtensionScanPersistenceService persistenceService; + @Mock TempFile extensionFile; + + private ExtensionScanService svc; + private ExtensionScan scan; + private UserData user; + + @BeforeEach + void setUp() { + svc = new ExtensionScanService(config, scanner, persistenceService); + + scan = new ExtensionScan(); + scan.setId(123); + scan.setStatus(ScanStatus.STARTED); + scan.setNamespaceName("ns"); + scan.setExtensionName("ext"); + scan.setExtensionVersion("1.0.0"); + scan.setTargetPlatform("universal"); + scan.setPublisher("publisher"); + + user = new UserData(); + user.setLoginName("testuser"); + + // Make mock persistence service actually update scan status for state machine validation + lenient().doAnswer(invocation -> { + ExtensionScan s = invocation.getArgument(0); + ScanStatus status = invocation.getArgument(1); + s.setStatus(status); + return null; + }).when(persistenceService).updateStatus(any(ExtensionScan.class), any(ScanStatus.class)); + + lenient().doAnswer(invocation -> { + ExtensionScan s = invocation.getArgument(0); + ScanStatus status = invocation.getArgument(1); + s.setStatus(status); + return null; + }).when(persistenceService).completeWithStatus(any(ExtensionScan.class), any(ScanStatus.class)); + + // Make mock persistence service actually add failures to scan + lenient().doAnswer(invocation -> { + ExtensionScan s = invocation.getArgument(0); + String checkType = invocation.getArgument(1); + String ruleName = invocation.getArgument(2); + String reason = invocation.getArgument(3); + boolean enforced = invocation.getArgument(4); + + var failure = ExtensionValidationFailure.create(checkType, ruleName, reason); + failure.setEnforced(enforced); + s.addValidationFailure(failure); + return null; + }).when(persistenceService).recordValidationFailure(any(), any(), any(), any(), anyBoolean()); + } + + // ========== HAPPY PATH TESTS ========== + + @Test + void runValidation_passes_whenAllChecksPass() { + // Scanner returns no findings + when(scanner.runValidationChecks(any(), any(), any())) + .thenReturn(new ExtensionScanner.ScanResult(List.of(), false, null, null)); + + // Act - should not throw + svc.runValidation(scan, extensionFile, user); + + // Assert: no failures recorded, validation lifecycle methods called + verify(persistenceService).updateStatus(scan, ScanStatus.VALIDATING); + verify(persistenceService, never()).recordValidationFailure(any(), any(), any(), any(), anyBoolean()); + } + + @Test + void runValidation_delegatesToScanner() { + // Scanner returns pass + when(scanner.runValidationChecks(eq(scan), eq(extensionFile), eq(user))) + .thenReturn(new ExtensionScanner.ScanResult(List.of(), false, null, null)); + + // Act + svc.runValidation(scan, extensionFile, user); + + // Assert: scanner was called with correct arguments + verify(scanner).runValidationChecks(scan, extensionFile, user); + } + + // ========== ENFORCEMENT TESTS ========== + + @Test + void runValidation_doesNotThrow_whenFailuresNotEnforced_butPersistsFailures() { + // Scanner returns failures but not enforced + var findings = List.of( + new ExtensionScanner.CheckFinding("CHECK_1", "rule1", "reason1", false), + new ExtensionScanner.CheckFinding("CHECK_2", "rule2", "reason2", false) + ); + when(scanner.runValidationChecks(any(), any(), any())) + .thenReturn(new ExtensionScanner.ScanResult(findings, false, null, null)); + + // Act: should NOT throw because nothing is enforced + svc.runValidation(scan, extensionFile, user); + + // Assert: failures persisted even in monitor-only mode + verify(persistenceService).recordValidationFailure(eq(scan), eq("CHECK_1"), eq("rule1"), eq("reason1"), eq(false)); + verify(persistenceService).recordValidationFailure(eq(scan), eq("CHECK_2"), eq("rule2"), eq("reason2"), eq(false)); + } + + @Test + void runValidation_throwsWhenEnforcedCheckFails() { + // Scanner returns enforced failure + var findings = List.of( + new ExtensionScanner.CheckFinding("CHECK_1", "rule1", "reason1", true) + ); + when(scanner.runValidationChecks(any(), any(), any())) + .thenReturn(new ExtensionScanner.ScanResult(findings, true, null, null)); + + // Act & Assert: should throw ErrorResultException + assertThatThrownBy(() -> svc.runValidation(scan, extensionFile, user)) + .isInstanceOf(ErrorResultException.class); + + verify(persistenceService).recordValidationFailure(eq(scan), eq("CHECK_1"), any(), any(), eq(true)); + } + + @Test + void runValidation_throwsWhenBothEnforced_andBothFail() { + // Scanner returns multiple enforced failures + var findings = List.of( + new ExtensionScanner.CheckFinding("CHECK_1", "rule1", "reason1", true), + new ExtensionScanner.CheckFinding("CHECK_2", "rule2", "reason2", true) + ); + when(scanner.runValidationChecks(any(), any(), any())) + .thenReturn(new ExtensionScanner.ScanResult(findings, true, null, null)); + + // Act & Assert + assertThatThrownBy(() -> svc.runValidation(scan, extensionFile, user)) + .isInstanceOf(ErrorResultException.class); + + verify(persistenceService).recordValidationFailure(eq(scan), eq("CHECK_1"), any(), any(), eq(true)); + verify(persistenceService).recordValidationFailure(eq(scan), eq("CHECK_2"), any(), any(), eq(true)); + } + + @Test + void runValidation_throwsWhenOneEnforcedFails_andOneNotEnforcedFails() { + // Mixed: one enforced failure, one non-enforced warning + var findings = List.of( + new ExtensionScanner.CheckFinding("CHECK_1", "rule1", "reason1", true), + new ExtensionScanner.CheckFinding("CHECK_2", "rule2", "reason2", false) + ); + when(scanner.runValidationChecks(any(), any(), any())) + .thenReturn(new ExtensionScanner.ScanResult(findings, true, null, null)); + + // Act & Assert: blocked because at least one enforced check failed + assertThatThrownBy(() -> svc.runValidation(scan, extensionFile, user)) + .isInstanceOf(ErrorResultException.class); + + verify(persistenceService).recordValidationFailure(eq(scan), eq("CHECK_1"), any(), any(), eq(true)); + verify(persistenceService).recordValidationFailure(eq(scan), eq("CHECK_2"), any(), any(), eq(false)); + } + + // ========== ERROR HANDLING TESTS ========== + + @Test + void runValidation_marksErrorAndRethrows_whenScannerReturnsError() { + var exception = new RuntimeException("Check failed unexpectedly"); + when(scanner.runValidationChecks(any(), any(), any())) + .thenReturn(new ExtensionScanner.ScanResult(List.of(), false, exception, "CHECK_1")); + + // Act & Assert + assertThatThrownBy(() -> svc.runValidation(scan, extensionFile, user)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Check failed unexpectedly"); + + verify(persistenceService).markAsErrored(eq(scan), anyString()); + } + + // ========== LIFECYCLE TESTS ========== + + @Test + void runValidation_transitionsToValidating() { + when(scanner.runValidationChecks(any(), any(), any())) + .thenReturn(new ExtensionScanner.ScanResult(List.of(), false, null, null)); + + // Act + svc.runValidation(scan, extensionFile, user); + + // Assert: transitions to VALIDATING + verify(persistenceService).updateStatus(scan, ScanStatus.VALIDATING); + } + + @Test + void runValidation_completesNormally_whenFailuresNotEnforced() { + // Scanner returns non-enforced failures (warnings) + var findings = List.of( + new ExtensionScanner.CheckFinding("CHECK_1", "rule1", "reason1", false), + new ExtensionScanner.CheckFinding("CHECK_2", "rule2", "reason2", false) + ); + when(scanner.runValidationChecks(any(), any(), any())) + .thenReturn(new ExtensionScanner.ScanResult(findings, false, null, null)); + + // Act - should not throw + svc.runValidation(scan, extensionFile, user); + + // Assert: completes normally (no exception) + verify(persistenceService).updateStatus(scan, ScanStatus.VALIDATING); + } + + @Test + void runValidation_transitionsToRejected_whenEnforcedFailure() { + var findings = List.of( + new ExtensionScanner.CheckFinding("CHECK_1", "rule1", "reason1", true) + ); + when(scanner.runValidationChecks(any(), any(), any())) + .thenReturn(new ExtensionScanner.ScanResult(findings, true, null, null)); + + // Act & Assert: throws and transitions to REJECTED + assertThatThrownBy(() -> svc.runValidation(scan, extensionFile, user)) + .isInstanceOf(ErrorResultException.class); + + verify(persistenceService).completeWithStatus(scan, ScanStatus.REJECTED); + } + + // ========== MULTIPLE FINDINGS TESTS ========== + + @Test + void runValidation_recordsAllFindings() { + // Scanner returns multiple findings from one check type + var findings = List.of( + new ExtensionScanner.CheckFinding("CHECK_1", "rule1", "reason1", false), + new ExtensionScanner.CheckFinding("CHECK_1", "rule2", "reason2", false), + new ExtensionScanner.CheckFinding("CHECK_1", "rule3", "reason3", false) + ); + when(scanner.runValidationChecks(any(), any(), any())) + .thenReturn(new ExtensionScanner.ScanResult(findings, false, null, null)); + + // Act + svc.runValidation(scan, extensionFile, user); + + // Assert: all 3 findings recorded + verify(persistenceService, times(3)).recordValidationFailure(eq(scan), eq("CHECK_1"), any(), any(), eq(false)); + } +} diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/SecretScannerFactoryTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/SecretScannerFactoryTest.java index 8294d12ef..273798b93 100644 --- a/server/src/test/java/org/eclipse/openvsx/scanning/SecretScannerFactoryTest.java +++ b/server/src/test/java/org/eclipse/openvsx/scanning/SecretScannerFactoryTest.java @@ -43,17 +43,18 @@ void initialize_buildsEvenWhenDisabled() throws Exception { } @Test - void initialize_skipsWhenNotConfigured() throws Exception { - // Factory skips initialization if ovsx.secret-scanning block is not present + void initialize_skipsWhenNoRulesConfigured() throws Exception { + // Factory skips initialization when enabled but no rule paths are configured + // Note: With @ConditionalOnProperty, factory is only created when enabled=true TrackingRuleLoader loader = new TrackingRuleLoader(); - SecretScanningConfig config = buildConfig(false); // disabled - MockGitleaksRulesGenerator generator = new MockGitleaksRulesGenerator(null); + SecretScanningConfig config = buildConfig(true); // enabled but no rules paths + MockGitleaksRulesGenerator generator = new MockGitleaksRulesGenerator(null); // no generated rules either SecretScannerFactory factory = new SecretScannerFactory(loader, config, generator); factory.initialize(); - assertFalse(loader.wasCalled, "Loader should not run when secret scanning is not configured"); - assertNull(factory.getScanner(), "Scanner should not be created when not configured"); + assertFalse(loader.wasCalled, "Loader should not run when no rules are configured"); + assertNull(factory.getScanner(), "Scanner should not be created when no rules configured"); assertTrue(factory.getRules().isEmpty(), "Rules should remain empty"); assertTrue(factory.getKeywordToRules().isEmpty(), "Keyword index should remain empty"); } diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/SecretScanningServiceTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/SecretScanningServiceTest.java index f1d3dcb93..90e2f8570 100644 --- a/server/src/test/java/org/eclipse/openvsx/scanning/SecretScanningServiceTest.java +++ b/server/src/test/java/org/eclipse/openvsx/scanning/SecretScanningServiceTest.java @@ -91,7 +91,8 @@ void loadRules_failsFastWhenMissing() { private SecretScanningService buildServiceWithLimits(int maxEntries, long maxBytes, int maxFindings) throws Exception { SecretScanningConfig config = new SecretScanningConfig(); - setField(config, "enabled", true); + // Disable scanning so we can construct the service without loading rule files. + setField(config, "enabled", false); setField(config, "maxFileSizeBytes", 1024 * 1024); setField(config, "maxLineLength", 10_000); setField(config, "timeoutSeconds", 10); diff --git a/server/src/test/java/org/eclipse/openvsx/search/SimilarityCheckServiceTest.java b/server/src/test/java/org/eclipse/openvsx/search/SimilarityCheckServiceTest.java index db6f84419..e43f5b7f4 100644 --- a/server/src/test/java/org/eclipse/openvsx/search/SimilarityCheckServiceTest.java +++ b/server/src/test/java/org/eclipse/openvsx/search/SimilarityCheckServiceTest.java @@ -12,11 +12,10 @@ ********************************************************************************/ package org.eclipse.openvsx.search; -import org.eclipse.openvsx.entities.Extension; -import org.eclipse.openvsx.entities.Namespace; -import org.eclipse.openvsx.entities.NamespaceMembership; -import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.entities.*; import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.scanning.ValidationCheck; +import org.eclipse.openvsx.util.TempFile; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -48,6 +47,9 @@ class SimilarityCheckServiceTest { @InjectMocks SimilarityCheckService similarityCheckService; + @Mock + TempFile extensionFile; + UserData user; @BeforeEach @@ -56,6 +58,15 @@ void setUp() { user.setLoginName("testuser"); } + /** Helper to create a ValidationCheck.Context for testing check() method */ + private ValidationCheck.Context createContext(String namespaceName, String extensionName, String displayName) { + var scan = new ExtensionScan(); + scan.setNamespaceName(namespaceName); + scan.setExtensionName(extensionName); + scan.setExtensionDisplayName(displayName); + return new ValidationCheck.Context(scan, extensionFile, user); + } + @Test void isEnabled_shouldDelegateToConfig() { when(config.isEnabled()).thenReturn(true); @@ -135,34 +146,33 @@ void shouldDelegateSimilarExtensionsToService() { @Test void shouldSkipCheckForExistingExtensionWhenConfiguredForNewOnly() { - // When configured for new extensions only, skip if extension already has versions. + // When configured for new extensions only, skip if extension already has versions (>1 means existing). when(config.isNewExtensionsOnly()).thenReturn(true); - when(repositories.countVersions("ns", "ext")).thenReturn(1); + when(repositories.countVersions("ns", "ext")).thenReturn(2); - var result = similarityCheckService.findSimilarExtensionsForPublishing( - "ext", "ns", "Display", user - ); + var context = createContext("ns", "ext", "Display"); + var result = similarityCheckService.check(context); - assertThat(result).isEmpty(); + assertThat(result.passed()).isTrue(); verify(repositories).countVersions("ns", "ext"); verifyNoInteractions(similarityService); } @Test void shouldCheckNewExtensionEvenWhenConfiguredForNewOnly() { - // When configured for new extensions only, still check if extension has no versions. + // When configured for new extensions only, still check if extension has 0 or 1 version. when(config.isNewExtensionsOnly()).thenReturn(true); + when(config.isExcludeOwnerNamespaces()).thenReturn(false); when(config.getLevenshteinThreshold()).thenReturn(0.15); when(config.isCheckAgainstVerifiedOnly()).thenReturn(false); - when(repositories.countVersions("ns", "ext")).thenReturn(0); + when(repositories.countVersions("ns", "ext")).thenReturn(1); when(similarityService.findSimilarExtensions("ext", "ns", "Display", List.of(), 0.15, false, 10)) .thenReturn(List.of()); - var result = similarityCheckService.findSimilarExtensionsForPublishing( - "ext", "ns", "Display", user - ); + var context = createContext("ns", "ext", "Display"); + var result = similarityCheckService.check(context); - assertThat(result).isEmpty(); + assertThat(result.passed()).isTrue(); verify(repositories).countVersions("ns", "ext"); verify(similarityService).findSimilarExtensions("ext", "ns", "Display", List.of(), 0.15, false, 10); } @@ -170,16 +180,16 @@ void shouldCheckNewExtensionEvenWhenConfiguredForNewOnly() { @Test void shouldSkipCheckForVerifiedPublisherWhenConfigured() { // When configured to skip verified publishers, check if namespace has owner memberships. + when(config.isNewExtensionsOnly()).thenReturn(false); when(config.isSkipVerifiedPublishers()).thenReturn(true); var namespace = new Namespace(); when(repositories.findNamespace("ns")).thenReturn(namespace); when(repositories.hasMemberships(namespace, NamespaceMembership.ROLE_OWNER)).thenReturn(true); - var result = similarityCheckService.findSimilarExtensionsForPublishing( - "ext", "ns", "Display", user - ); + var context = createContext("ns", "ext", "Display"); + var result = similarityCheckService.check(context); - assertThat(result).isEmpty(); + assertThat(result.passed()).isTrue(); verify(repositories).findNamespace("ns"); verify(repositories).hasMemberships(namespace, NamespaceMembership.ROLE_OWNER); verifyNoInteractions(similarityService); @@ -188,17 +198,18 @@ void shouldSkipCheckForVerifiedPublisherWhenConfigured() { @Test void shouldCheckVerifiedPublisherWhenSkipIsDisabled() { // When skip verified publishers is disabled, check even if namespace has owner memberships. + when(config.isNewExtensionsOnly()).thenReturn(false); when(config.isSkipVerifiedPublishers()).thenReturn(false); + when(config.isExcludeOwnerNamespaces()).thenReturn(false); when(config.getLevenshteinThreshold()).thenReturn(0.15); when(config.isCheckAgainstVerifiedOnly()).thenReturn(false); when(similarityService.findSimilarExtensions("ext", "ns", "Display", List.of(), 0.15, false, 10)) .thenReturn(List.of()); - var result = similarityCheckService.findSimilarExtensionsForPublishing( - "ext", "ns", "Display", user - ); + var context = createContext("ns", "ext", "Display"); + var result = similarityCheckService.check(context); - assertThat(result).isEmpty(); + assertThat(result.passed()).isTrue(); verify(similarityService).findSimilarExtensions("ext", "ns", "Display", List.of(), 0.15, false, 10); } diff --git a/webui/src/components/scan-admin/common/auto-refresh.tsx b/webui/src/components/scan-admin/common/auto-refresh.tsx new file mode 100644 index 000000000..80e60c9c9 --- /dev/null +++ b/webui/src/components/scan-admin/common/auto-refresh.tsx @@ -0,0 +1,79 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import React, { FunctionComponent } from 'react'; +import { Box, Typography, Switch } from '@mui/material'; +import { formatDateTime } from './utils'; + +interface AutoRefreshProps { + lastRefreshed: Date | null; + autoRefresh?: boolean; + onAutoRefreshChange?: (enabled: boolean) => void; +} + +export const AutoRefresh: FunctionComponent = ({ + lastRefreshed, + autoRefresh = false, + onAutoRefreshChange, +}) => { + if (!lastRefreshed) { + return null; + } + + return ( + + {onAutoRefreshChange && ( + + + 30s auto-refresh + + onAutoRefreshChange(e.target.checked)} + color='secondary' + sx={{ + transform: 'scale(0.7)', + marginRight: -0.5, + }} + /> + + )} + + Last Refreshed: {formatDateTime(lastRefreshed.toISOString())} + + + ); +}; diff --git a/webui/src/components/scan-admin/common/conditional-tooltip.tsx b/webui/src/components/scan-admin/common/conditional-tooltip.tsx new file mode 100644 index 000000000..1b1ff6591 --- /dev/null +++ b/webui/src/components/scan-admin/common/conditional-tooltip.tsx @@ -0,0 +1,74 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import React, { FunctionComponent, useRef, useState, useEffect } from 'react'; +import { Tooltip, TooltipProps } from '@mui/material'; + +interface ConditionalTooltipProps extends Omit { + title: string; +} + +/** + * A Tooltip component that only shows when the child content is truncated with ellipsis. + * Uses ref to check if the content overflows its container. + */ +export const ConditionalTooltip: FunctionComponent = ({ + title, + children, + ...tooltipProps +}) => { + const textRef = useRef(null); + const [isTruncated, setIsTruncated] = useState(false); + + useEffect(() => { + const checkTruncation = () => { + if (textRef.current) { + setIsTruncated(textRef.current.scrollWidth > textRef.current.clientWidth); + } + }; + + checkTruncation(); + + // Recheck on window resize + window.addEventListener('resize', checkTruncation); + return () => window.removeEventListener('resize', checkTruncation); + }, [title, children]); + + return ( + + {React.cloneElement(children as React.ReactElement, { + ref: textRef, + })} + + ); +}; diff --git a/webui/src/components/scan-admin/common/file-table.tsx b/webui/src/components/scan-admin/common/file-table.tsx new file mode 100644 index 000000000..8cf852b5a --- /dev/null +++ b/webui/src/components/scan-admin/common/file-table.tsx @@ -0,0 +1,508 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import React, { FunctionComponent, useState } from 'react'; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Checkbox, + Typography, + Chip, +} from '@mui/material'; +import { FileDecision } from '../../../context/scan-admin'; +import { ConditionalTooltip, formatDateTime } from './index'; +import { useTheme } from '@mui/material/styles'; + +interface FileTableProps { + files: FileDecision[]; + type: 'allowed' | 'blocked'; + selectedFiles?: Set; + onSelectionChange?: (fileIds: Set) => void; +} + +export const FileTable: FunctionComponent = (props) => { + const { files, type, selectedFiles = new Set(), onSelectionChange } = props; + const [internalSelection, setInternalSelection] = useState>(selectedFiles); + const theme = useTheme(); + const [columnWidths, setColumnWidths] = useState({ + file: 250, + type: 100, + date: 200, + actionBy: 130, + extension: 250, + publisher: 150, + version: 100, + }); + + const selection = onSelectionChange ? selectedFiles : internalSelection; + const setSelection = onSelectionChange || setInternalSelection; + + const handleSelectAll = (event: React.ChangeEvent) => { + if (event.target.checked) { + const allIds = new Set(files.map(f => f.id)); + setSelection(allIds); + } else { + setSelection(new Set()); + } + }; + + const handleSelectOne = (fileId: string) => { + const newSelection = new Set(selection); + if (newSelection.has(fileId)) { + newSelection.delete(fileId); + } else { + newSelection.add(fileId); + } + setSelection(newSelection); + }; + + const isSelected = (fileId: string) => selection.has(fileId); + const allSelected = files.length > 0 && selection.size === files.length; + const someSelected = selection.size > 0 && selection.size < files.length; + + const handleDoubleClick = (column: keyof typeof columnWidths, event: React.MouseEvent) => { + event.preventDefault(); + + const minWidth = 80; + const columns: (keyof typeof columnWidths)[] = ['file', 'type', 'date', 'actionBy', 'extension', 'publisher', 'version']; + const currentIndex = columns.indexOf(column); + + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) return; + + context.font = '14px Roboto, sans-serif'; + + let idealWidth = minWidth; + const padding = 32; + + const headerTexts: Record = { + file: 'File', + type: 'Type', + date: type === 'allowed' ? 'Date Allowed' : 'Date Blocked', + actionBy: type === 'allowed' ? 'Allowed By' : 'Blocked By', + extension: 'Extension', + publisher: 'Publisher', + version: 'Version', + }; + idealWidth = Math.max(idealWidth, context.measureText(headerTexts[column]).width + padding); + + files.forEach(file => { + let text = ''; + switch (column) { + case 'file': { + const fileNameWidth = context.measureText(file.fileName).width; + const fileHashWidth = context.measureText(file.fileHash).width; + idealWidth = Math.max(idealWidth, fileNameWidth + padding, fileHashWidth + padding); + return; + } + case 'type': + text = file.fileType; + break; + case 'date': { + text = formatDateTime(file.dateDecided); + break; + } + case 'actionBy': + text = file.decidedBy; + break; + case 'extension': { + const displayNameWidth = context.measureText(file.displayName).width; + const namespaceWidth = context.measureText(`${file.namespace}.${file.extensionName}`).width; + idealWidth = Math.max(idealWidth, displayNameWidth + padding, namespaceWidth + padding); + return; + } + case 'publisher': + text = file.publisher; + break; + case 'version': + text = file.version; + break; + } + + if (text) { + const width = context.measureText(text).width + padding; + idealWidth = Math.max(idealWidth, width); + } + }); + + idealWidth = Math.min(idealWidth, 600); + + const currentWidth = columnWidths[column]; + const growthNeeded = idealWidth - currentWidth; + + if (growthNeeded <= 0) { + setColumnWidths(prev => ({ + ...prev, + [column]: idealWidth, + })); + return; + } + + let availableSpace = 0; + for (let i = currentIndex + 1; i < columns.length; i++) { + const col = columns[i]; + availableSpace += columnWidths[col] - minWidth; + } + + if (availableSpace <= 0) { + return; + } + + const actualGrowth = Math.min(growthNeeded, availableSpace); + const newWidth = currentWidth + actualGrowth; + + const newWidths = { ...columnWidths }; + newWidths[column] = newWidth; + + let remainingShrink = actualGrowth; + for (let i = currentIndex + 1; i < columns.length && remainingShrink > 0; i++) { + const col = columns[i]; + const availableShrink = columnWidths[col] - minWidth; + const shrinkAmount = Math.min(remainingShrink, availableShrink); + newWidths[col] = columnWidths[col] - shrinkAmount; + remainingShrink -= shrinkAmount; + } + + setColumnWidths(newWidths); + }; + + const handleMouseDown = (column: keyof typeof columnWidths, event: React.MouseEvent) => { + event.preventDefault(); + const startX = event.clientX; + const startWidths = { ...columnWidths }; + const minWidth = 80; + + const columns: (keyof typeof columnWidths)[] = ['file', 'type', 'date', 'actionBy', 'extension', 'publisher', 'version']; + const currentIndex = columns.indexOf(column); + + if (currentIndex === columns.length - 1) return; + + const handleMouseMove = (e: MouseEvent) => { + const diff = e.clientX - startX; + const newWidths = { ...startWidths }; + + if (diff > 0) { + let remainingDiff = diff; + const currentGrowth = Math.min(remainingDiff, Number.MAX_SAFE_INTEGER); + newWidths[column] = startWidths[column] + currentGrowth; + + for (let i = currentIndex + 1; i < columns.length && remainingDiff > 0; i++) { + const col = columns[i]; + const availableShrink = startWidths[col] - minWidth; + const shrinkAmount = Math.min(remainingDiff, availableShrink); + newWidths[col] = startWidths[col] - shrinkAmount; + remainingDiff -= shrinkAmount; + } + + newWidths[column] = startWidths[column] + (diff - remainingDiff); + + } else if (diff < 0) { + let remainingDiff = Math.abs(diff); + + const currentShrink = Math.min(remainingDiff, startWidths[column] - minWidth); + newWidths[column] = startWidths[column] - currentShrink; + remainingDiff -= currentShrink; + + if (remainingDiff > 0) { + for (let i = currentIndex - 1; i >= 0 && remainingDiff > 0; i--) { + const col = columns[i]; + const availableShrink = startWidths[col] - minWidth; + const shrinkAmount = Math.min(remainingDiff, availableShrink); + newWidths[col] = startWidths[col] - shrinkAmount; + remainingDiff -= shrinkAmount; + } + } + + const totalShrunk = Math.abs(diff) - remainingDiff; + newWidths[columns[currentIndex + 1]] = startWidths[columns[currentIndex + 1]] + totalShrunk; + } + + setColumnWidths(newWidths); + }; + + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + + const cellStyle = { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' as const, + }; + + const truncateStyle = { + display: 'block', + ...cellStyle, + }; + + return ( + + + + + + + + + File +
handleMouseDown('file', e)} + onDoubleClick={(e) => handleDoubleClick('file', e)} + style={{ + position: 'absolute', + right: 0, + top: 0, + bottom: 0, + width: '5px', + cursor: 'col-resize', + userSelect: 'none', + }} + /> + + + Type +
handleMouseDown('type', e)} + onDoubleClick={(e) => handleDoubleClick('type', e)} + style={{ + position: 'absolute', + right: 0, + top: 0, + bottom: 0, + width: '5px', + cursor: 'col-resize', + userSelect: 'none', + }} + /> + + + {type === 'allowed' ? 'Date Allowed' : 'Date Blocked'} +
handleMouseDown('date', e)} + onDoubleClick={(e) => handleDoubleClick('date', e)} + style={{ + position: 'absolute', + right: 0, + top: 0, + bottom: 0, + width: '5px', + cursor: 'col-resize', + userSelect: 'none', + }} + /> + + + {type === 'allowed' ? 'Allowed By' : 'Blocked By'} +
handleMouseDown('actionBy', e)} + onDoubleClick={(e) => handleDoubleClick('actionBy', e)} + style={{ + position: 'absolute', + right: 0, + top: 0, + bottom: 0, + width: '5px', + cursor: 'col-resize', + userSelect: 'none', + }} + /> + + + Extension +
handleMouseDown('extension', e)} + onDoubleClick={(e) => handleDoubleClick('extension', e)} + style={{ + position: 'absolute', + right: 0, + top: 0, + bottom: 0, + width: '5px', + cursor: 'col-resize', + userSelect: 'none', + }} + /> + + + Publisher +
handleMouseDown('publisher', e)} + onDoubleClick={(e) => handleDoubleClick('publisher', e)} + style={{ + position: 'absolute', + right: 0, + top: 0, + bottom: 0, + width: '5px', + cursor: 'col-resize', + userSelect: 'none', + }} + /> + + + Version +
handleMouseDown('version', e)} + onDoubleClick={(e) => handleDoubleClick('version', e)} + style={{ + position: 'absolute', + right: 0, + top: 0, + bottom: 0, + width: '5px', + cursor: 'col-resize', + userSelect: 'none', + }} + /> + + + + + {files.length === 0 ? ( + + + + No {type} files + + + + ) : ( + files.map((file) => { + const selected = isSelected(file.id); + const dateField = file.dateDecided; + const actionByField = file.decidedBy; + + return ( + handleSelectOne(file.id)} + selected={selected} + sx={{ + cursor: 'pointer', + '&.Mui-selected': { + backgroundColor: theme.palette.selected.background, + }, + '&.Mui-selected:hover': { + backgroundColor: theme.palette.selected.backgroundHover, + }, + }} + > + + + + + + + {file.fileName} + + + + + {file.fileHash} + + + + + + + + + + {formatDateTime(dateField)} + + + + + + + {actionByField} + + + + + + {file.displayName} + + + + {file.namespace}.{file.extensionName} + + + + + + {file.publisher} + + + + + {file.version} + + + + ); + }) + )} + +
+
+ ); +}; diff --git a/webui/src/components/scan-admin/common/index.ts b/webui/src/components/scan-admin/common/index.ts new file mode 100644 index 000000000..2be89eb43 --- /dev/null +++ b/webui/src/components/scan-admin/common/index.ts @@ -0,0 +1,18 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +export { ConditionalTooltip } from './conditional-tooltip'; +export { FileTable } from './file-table'; +export { AutoRefresh } from './auto-refresh'; +export { TabPanel } from './tab-panel'; +export { formatDuration, formatDateTime } from './utils'; diff --git a/webui/src/components/scan-admin/common/tab-panel.tsx b/webui/src/components/scan-admin/common/tab-panel.tsx new file mode 100644 index 000000000..baa9de53a --- /dev/null +++ b/webui/src/components/scan-admin/common/tab-panel.tsx @@ -0,0 +1,42 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import React, { FunctionComponent, ReactNode } from 'react'; +import { Box } from '@mui/material'; + +interface TabPanelProps { + children?: ReactNode; + value: number; + index: number; +} + +/** + * TabPanel component for conditionally displaying tab content. + * Shows children when the selected tab value matches this panel's index. + */ +export const TabPanel: FunctionComponent = ({ + children, + value, + index, +}) => { + return ( + + ); +}; diff --git a/webui/src/components/scan-admin/common/utils.ts b/webui/src/components/scan-admin/common/utils.ts new file mode 100644 index 000000000..058ceabc3 --- /dev/null +++ b/webui/src/components/scan-admin/common/utils.ts @@ -0,0 +1,48 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +// ============================================================================ +// Date/Time Formatting Utilities +// ============================================================================ + +export const formatDuration = (start: string, end?: string): string => { + if (!end) return 'Scan in progress...'; + const duration = new Date(end).getTime() - new Date(start).getTime(); + if (duration < 1000) return '< 1 sec'; + const seconds = Math.floor(duration / 1000); + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes === 0) { + return `${remainingSeconds} sec`; + } + return `${minutes} min ${remainingSeconds} sec`; +}; + +export const formatDateTime = (isoString: string): string => { + const date = new Date(isoString); + const pad = (n: number) => n < 10 ? '0' + n : n.toString(); + + const year = date.getFullYear(); + const month = pad(date.getMonth() + 1); + const day = pad(date.getDate()); + let hours = date.getHours(); + const minutes = pad(date.getMinutes()); + const seconds = pad(date.getSeconds()); + const ampm = hours >= 12 ? 'PM' : 'AM'; + hours = hours % 12; + hours = hours ? hours : 12; + const formattedHours = pad(hours); + const timeZone = date.toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ')[2]; + + return `${year}-${month}-${day} ${formattedHours}:${minutes}:${seconds} ${ampm} ${timeZone}`; +}; diff --git a/webui/src/components/scan-admin/dialogs/file-dialog.tsx b/webui/src/components/scan-admin/dialogs/file-dialog.tsx new file mode 100644 index 000000000..7b1013766 --- /dev/null +++ b/webui/src/components/scan-admin/dialogs/file-dialog.tsx @@ -0,0 +1,137 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import React, { FunctionComponent } from 'react'; +import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, List, ListItem, ListItemText, Box } from '@mui/material'; +import { useDialogs } from '../../../hooks/scan-admin'; +import { useTheme } from '@mui/material/styles'; +import { FileDecision } from '../../../context/scan-admin'; + +/** + * Confirmation dialog for file-level allow/block/delete actions. + * Uses the useDialogs hook to consume context. + */ +export const FileDialog: FunctionComponent = () => { + const theme = useTheme(); + const { fileDialog } = useDialogs(); + + return ( +

+ + {fileDialog.actionType === 'allow' ? 'Confirm Allow Files' : fileDialog.actionType === 'block' ? 'Confirm Block Files' : 'Confirm Delete Files'} + + + + Are you sure you want to {fileDialog.actionType} {fileDialog.selectedFiles.length !== 1 ? 'these' : 'this'} {fileDialog.selectedFiles.length} file{fileDialog.selectedFiles.length !== 1 ? 's' : ''}? + + + {fileDialog.selectedFiles.map((file: FileDecision) => ( + + + {file.fileName} + + } + secondary={ + + + Hash: {file.fileHash} + + + {file.displayName} + + + {file.namespace}.{file.extensionName} + + + Version: {file.version} + + + } + /> + + ))} + + + + + + + + ); +}; diff --git a/webui/src/components/scan-admin/dialogs/index.ts b/webui/src/components/scan-admin/dialogs/index.ts new file mode 100644 index 000000000..e16f72fa3 --- /dev/null +++ b/webui/src/components/scan-admin/dialogs/index.ts @@ -0,0 +1,15 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +export { QuarantineDialog } from './quarantine-dialog'; +export { FileDialog } from './file-dialog'; diff --git a/webui/src/components/scan-admin/dialogs/quarantine-dialog.tsx b/webui/src/components/scan-admin/dialogs/quarantine-dialog.tsx new file mode 100644 index 000000000..6037b9c8a --- /dev/null +++ b/webui/src/components/scan-admin/dialogs/quarantine-dialog.tsx @@ -0,0 +1,236 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import React, { FunctionComponent, useMemo } from 'react'; +import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, List, ListItem, ListItemText, Box, Tooltip } from '@mui/material'; +import { Info as InfoIcon } from '@mui/icons-material'; +import { useDialogs } from '../../../hooks/scan-admin'; +import { useTheme } from '@mui/material/styles'; +import { ScanResult } from '../../../context/scan-admin'; + +/** + * Get only the enforced threats from a scan. + * Only enforced threats should have file decisions made on them. + */ +const getEnforcedThreats = (scan: ScanResult) => { + return scan.threats?.filter(threat => threat.enforcedFlag) || []; +}; + +/** + * Get only the unenforced threats from a scan. + */ +const getUnenforcedThreats = (scan: ScanResult) => { + return scan.threats?.filter(threat => !threat.enforcedFlag) || []; +}; + +/** + * Confirmation dialog for extension-level allow/block actions. + * Uses the useDialogs hook to consume context. + * + * Only files from ENFORCED threats are actionable. + * Unenforced threats are informational only and + * should not have allow/block decisions made on them. + */ +export const QuarantineDialog: FunctionComponent = () => { + const theme = useTheme(); + const { confirmDialog } = useDialogs(); + + // Check if any extension has unenforced threats (to show info message) + const hasAnyUnenforcedThreats = useMemo(() => { + return confirmDialog.selectedExtensions.some((scan: ScanResult) => getUnenforcedThreats(scan).length > 0); + }, [confirmDialog.selectedExtensions]); + + // Calculate total enforced files across all selected extensions + const totalEnforcedFiles = useMemo(() => { + return confirmDialog.selectedExtensions.reduce( + (total: number, scan: ScanResult) => total + getEnforcedThreats(scan).length, + 0 + ); + }, [confirmDialog.selectedExtensions]); + + return ( + + + {confirmDialog.action === 'allow' ? 'Confirm Allow' : 'Confirm Block'} + + + + Are you sure you want to {confirmDialog.action} {totalEnforcedFiles} enforced file{totalEnforcedFiles !== 1 ? 's' : ''} from {confirmDialog.selectedExtensions.length !== 1 ? 'these' : 'this'} {confirmDialog.selectedExtensions.length} extension{confirmDialog.selectedExtensions.length !== 1 ? 's' : ''}? + + + {/* Info message about unenforced threats */} + {hasAnyUnenforcedThreats && ( + + + + Some extensions have unenforced threats. These are informational only and will not be added to the {confirmDialog.action === 'allow' ? 'allow' : 'block'} list. + + + )} + + + {confirmDialog.selectedExtensions.map((scan: ScanResult) => { + const enforcedThreats = getEnforcedThreats(scan); + const unenforcedThreats = getUnenforcedThreats(scan); + + return ( + + + {scan.displayName} + + } + secondary={ + + + {scan.namespace}.{scan.extensionName} + + + Publisher: {scan.publisher} + + + Version: {scan.version} + + + } + /> + 0 ? ( + + + Files to {confirmDialog.action}: + + + {enforcedThreats.map((threat, index) => ( +
{threat.fileName}
+ ))} +
+ {unenforcedThreats.length > 0 && ( + <> + + Unenforced (not included): + + + {unenforcedThreats.map((threat, index) => ( +
{threat.fileName}
+ ))} +
+ + )} +
+ ) : ( + 'No enforced threats to action' + ) + } + arrow + > + + + {enforcedThreats.length} file{enforcedThreats.length !== 1 ? 's' : ''} + + {unenforcedThreats.length > 0 && ( + + +{unenforcedThreats.length} unenforced + + )} + +
+
+ ); + })} +
+
+ + + + +
+ ); +}; diff --git a/webui/src/components/scan-admin/index.ts b/webui/src/components/scan-admin/index.ts new file mode 100644 index 000000000..0124626a9 --- /dev/null +++ b/webui/src/components/scan-admin/index.ts @@ -0,0 +1,52 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +// Common components +export { + ConditionalTooltip, + FileTable, + AutoRefresh, + TabPanel, +} from './common'; + +// Dialog components +export { + QuarantineDialog, + FileDialog, +} from './dialogs'; + +// Toolbar components +export { + TabToolbar, + SearchToolbar, + CountsToolbar, +} from './toolbars'; + +// Tab content components +export { + ScansTabContent, + QuarantinedTabContent, + AutoRejectedTabContent, + AllowListTabContent, + BlockListTabContent, +} from './tab-contents'; + +// ScanCard components +export { ScanCard } from './scan-card'; +export { + ScanCardHeader, + ScanCardContent, + ScanCardExpandStrip, + ScanCardExpandedContent, +} from './scan-card'; +export * from './scan-card/utils'; diff --git a/webui/src/components/scan-admin/scan-card/index.ts b/webui/src/components/scan-admin/scan-card/index.ts new file mode 100644 index 000000000..200fb2e4d --- /dev/null +++ b/webui/src/components/scan-admin/scan-card/index.ts @@ -0,0 +1,20 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +export { ScanCard } from './scan-card'; +export { ScanCardHeader } from './scan-card-header'; +export { ScanCardContent } from './scan-card-content'; +export { ScanCardExpandStrip } from './scan-card-expand-strip'; +export { ScanCardExpandedContent } from './scan-card-expanded-content'; +export { ScanDetailCard } from './scan-detail-card'; +export * from './utils'; diff --git a/webui/src/components/scan-admin/scan-card/scan-card-content.tsx b/webui/src/components/scan-admin/scan-card/scan-card-content.tsx new file mode 100644 index 000000000..2fe75802d --- /dev/null +++ b/webui/src/components/scan-admin/scan-card/scan-card-content.tsx @@ -0,0 +1,457 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import React from 'react'; +import { Box, Typography, Link, IconButton, Tooltip } from '@mui/material'; +import { + Check as CheckIcon, + Warning as WarningAmberIcon, +} from '@mui/icons-material'; +import { ScanResult } from '../../../context/scan-admin'; +import { ConditionalTooltip, formatDateTime, formatDuration } from '../common'; +import { useTheme } from '@mui/material/styles'; +import { + isRunning, + hasDownload, + getFileName, +} from './utils'; + +interface ScanCardContentProps { + scan: ScanResult; + showCheckbox?: boolean; + checked?: boolean; + onCheckboxChange?: (id: string, checked: boolean) => void; + liveDuration: string; +} + +/** + * Content section of the ScanCard containing: + * - Publisher, Version, Download (Row 2) + * - Scan Start, Scan End, Duration, Decision Status (Row 3) + * - Checkbox for selection + */ +export const ScanCardContent: React.FC = ({ + scan, + showCheckbox, + checked, + onCheckboxChange, + liveDuration, +}) => { + const theme = useTheme(); + const [isCheckboxHovering, setIsCheckboxHovering] = React.useState(false); + + return ( + <> + {/* ROW 2 - Publisher, Version, Download, Checkbox */} + {/* Column 1: Empty (below icon) */} + + + {/* Column 2: Publisher */} + + + Publisher + + + + + {scan.publisher} + + + + + + {/* Column 3: Version */} + + + Version + + + + {scan.version} + + + + + {/* Column 4: Download */} + + + Download + + {isRunning(scan.status) ? ( + + N/A + + ) : hasDownload(scan) && scan.downloadUrl ? ( + + {scan.status === 'QUARANTINED' && ( + + + + )} + + + {getFileName(scan.downloadUrl)} + + + + ) : ( + + N/A + + )} + + + {/* Column 5: Checkbox */} + + {showCheckbox && ( + onCheckboxChange?.(scan.id, !checked)} + onMouseEnter={() => setIsCheckboxHovering(true)} + onMouseLeave={() => setIsCheckboxHovering(false)} + disableRipple + sx={{ + padding: 0, + width: 36, + height: 36, + backgroundColor: 'transparent', + }} + > + + + + + + )} + + + {/* ROW 3 - Scan Start, Scan End, Scan Duration, Decision Status */} + {/* Column 1: Empty (below icon) */} + + + {/* Column 2: Scan Start */} + + + Scan Start + + + + {formatDateTime(scan.dateScanStarted)} + + + + + {/* Column 3: Scan End */} + + + Scan End + + {isRunning(scan.status) ? ( + + + {scan.status}... + + + ) : scan.dateScanEnded ? ( + + + {formatDateTime(scan.dateScanEnded)} + + + ) : ( + + N/A + + )} + + + {/* Column 4: Scan Duration */} + + + Scan Duration + + {isRunning(scan.status) ? ( + + + {liveDuration} + + + ) : ( + + + {formatDuration(scan.dateScanStarted, scan.dateScanEnded || undefined)} + + + )} + + + {/* Column 5: Decision Status */} + {scan.status === 'QUARANTINED' && scan.adminDecision && ( + + + + {scan.adminDecision.decision.toLowerCase() === 'allowed' ? 'ALLOWED' : 'BLOCKED'} + + + + )} + {scan.status === 'QUARANTINED' && !scan.adminDecision && ( + + + NEEDS REVIEW + + + )} + + ); +}; diff --git a/webui/src/components/scan-admin/scan-card/scan-card-expand-strip-badges.tsx b/webui/src/components/scan-admin/scan-card/scan-card-expand-strip-badges.tsx new file mode 100644 index 000000000..376482733 --- /dev/null +++ b/webui/src/components/scan-admin/scan-card/scan-card-expand-strip-badges.tsx @@ -0,0 +1,180 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import React, { useRef, useEffect, useState, useLayoutEffect } from 'react'; +import { Box, Chip, Typography } from '@mui/material'; +import { useTheme, alpha } from '@mui/material/styles'; +import { DetailBadge } from './utils'; + +interface ScanCardExpandStripBadgesProps { + badges: DetailBadge[]; + containerWidth: number; + maxWidthPercent?: number; +} + +/** + * Renders badges with overflow handling - shows as many badges as fit + * within the available width, plus a "+ X more" indicator for hidden badges. + */ +export const ScanCardExpandStripBadges: React.FC = ({ + badges, + containerWidth, + maxWidthPercent = 0.45, +}) => { + const theme = useTheme(); + const measureRef = useRef(null); + const [visibleCount, setVisibleCount] = useState(null); + + const calculateVisibleBadges = () => { + if (!measureRef.current || badges.length === 0 || containerWidth <= 0) { + return; + } + + const availableWidth = containerWidth * maxWidthPercent; + const chips = measureRef.current.querySelectorAll('.measure-chip'); + + if (chips.length === 0) { + return; + } + + // 65px width for "+ X more" text + const moreIndicatorWidth = 65; + const gap = 4; // 0.5 spacing unit = 4px + + let totalWidth = 0; + let count = 0; + + for (let index = 0; index < chips.length; index++) { + const chip = chips[index] as HTMLElement; + const chipWidth = chip.offsetWidth; + const widthWithGap = chipWidth + (index > 0 ? gap : 0); + + // Check if this chip fits + // If not all badges fit, we need room for the "+ X more" indicator + const remainingBadges = badges.length - (index + 1); + const needsMoreIndicator = remainingBadges > 0; + const reservedWidth = needsMoreIndicator ? moreIndicatorWidth + gap : 0; + + if (totalWidth + widthWithGap + reservedWidth <= availableWidth) { + totalWidth += widthWithGap; + count++; + } else { + break; + } + } + + setVisibleCount(count); + }; + + // Use layoutEffect to measure before paint (to avoid flicker) + useLayoutEffect(() => { + calculateVisibleBadges(); + }, [badges, containerWidth]); + + // Recalculate when container width changes + useEffect(() => { + calculateVisibleBadges(); + }, [containerWidth]); + + // Use all badges until calculation completes + const effectiveVisibleCount = visibleCount ?? badges.length; + const visibleBadges = badges.slice(0, effectiveVisibleCount); + const hiddenCount = badges.length - effectiveVisibleCount; + + const chipStyles = (badge: DetailBadge) => ({ + height: '20px', + fontSize: '0.7rem', + flexShrink: 0, + ...(badge.type === 'threat' ? { + backgroundColor: badge.isEnforced ? theme.palette.quarantined.dark : undefined, + color: theme.palette.quarantined.light, + background: !badge.isEnforced ? `${theme.palette.unenforced.stripe}, ${theme.palette.quarantined.dark}` : undefined, + } : { + backgroundColor: badge.isEnforced ? theme.palette.rejected.dark : undefined, + color: theme.palette.rejected.light, + background: !badge.isEnforced ? `${theme.palette.unenforced.stripe}, ${theme.palette.rejected.dark}` : undefined, + }), + }); + + if (badges.length === 0) { + return null; + } + + return ( + <> + {/* Hidden measurement container - always renders all badges for measurement */} + + + {/* Visible container - only shows badges that fit */} + + {visibleBadges.map((badge, index) => ( + + ))} + {hiddenCount > 0 && ( + + + {hiddenCount} more + + )} + + + ); +}; diff --git a/webui/src/components/scan-admin/scan-card/scan-card-expand-strip.tsx b/webui/src/components/scan-admin/scan-card/scan-card-expand-strip.tsx new file mode 100644 index 000000000..a4bbd3c64 --- /dev/null +++ b/webui/src/components/scan-admin/scan-card/scan-card-expand-strip.tsx @@ -0,0 +1,104 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import React, { useRef, useEffect, useState } from 'react'; +import { Box } from '@mui/material'; +import { ExpandMore as ExpandMoreIcon } from '@mui/icons-material'; +import { useTheme } from '@mui/material/styles'; +import { DetailBadge } from './utils'; +import { ScanCardExpandStripBadges } from './scan-card-expand-strip-badges'; + +interface ScanCardExpandStripProps { + expanded: boolean; + onExpandClick: () => void; + badges: DetailBadge[]; + collapseComplete: boolean; +} + +/** + * Expandable strip at the bottom of the card + * Shows validationFailure/threat badges (click to expand/show detail cards) + */ +export const ScanCardExpandStrip: React.FC = ({ + expanded, + onExpandClick, + badges, + collapseComplete, +}) => { + const theme = useTheme(); + const [isHovering, setIsHovering] = useState(false); + const wrapperRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + + useEffect(() => { + const updateWidth = () => { + if (wrapperRef.current) { + setContainerWidth(wrapperRef.current.offsetWidth); + } + }; + + updateWidth(); + + const resizeObserver = new ResizeObserver(() => { + updateWidth(); + }); + + if (wrapperRef.current) { + resizeObserver.observe(wrapperRef.current); + } + + return () => resizeObserver.disconnect(); + }, []); + + return ( + setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + sx={{ + pb: 0, + pt: 0.5, + px: 5, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + cursor: 'pointer', + borderBottomRightRadius: expanded ? 0 : 8, + minHeight: 40, + backgroundColor: isHovering ? theme.palette.selected.hover : 'transparent', + transition: 'background-color 0.2s', + position: 'relative', + }} + > + + + + + + {!expanded && collapseComplete && ( + + )} + + ); +}; \ No newline at end of file diff --git a/webui/src/components/scan-admin/scan-card/scan-card-expanded-content.tsx b/webui/src/components/scan-admin/scan-card/scan-card-expanded-content.tsx new file mode 100644 index 000000000..98fe04c7c --- /dev/null +++ b/webui/src/components/scan-admin/scan-card/scan-card-expanded-content.tsx @@ -0,0 +1,153 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import React from 'react'; +import { Box, Typography, Collapse } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import { ScanResult, Threat, ValidationFailure } from '../../../context/scan-admin'; +import { ScanDetailCard } from './scan-detail-card'; +import { formatDateTime } from '../common'; + +interface ScanCardExpandedContentProps { + scan: ScanResult; + expanded: boolean; + onCollapseComplete?: () => void; +} + +interface ThreatItemProps { + threat: Threat; +} + +interface ValidationFailureItemProps { + failure: ValidationFailure; +} + +/** + * A single threat item in the expanded content. + */ +const ThreatItem: React.FC = ({ threat }) => { + const theme = useTheme(); + + return ( + + ); +}; + +/** + * A single validation failure item in the expanded content. + */ +const ValidationFailureItem: React.FC = ({ failure }) => { + const theme = useTheme(); + const isUnenforced = !failure.enforcedFlag; + + return ( + + ); +}; + +/** + * The expanded content section showing threats and validation failures. + * Each item's enforcedFlag controls its individual striping effect. + */ +export const ScanCardExpandedContent: React.FC = ({ scan, expanded, onCollapseComplete }) => { + const theme = useTheme(); + const hasThreats = scan.threats.length > 0; + const hasValidationFailures = scan.validationFailures.length > 0; + const hasErrorMessage = scan.status === 'ERROR' && scan.errorMessage; + + return ( + + + {/* Error Message */} + {hasErrorMessage && ( + + + + )} + + {/* Threats */} + {hasThreats && ( + + {hasValidationFailures && ( + + Threats + + )} + + {scan.threats.map((threat, index) => ( + + ))} + + + )} + + {/* Validation Failures */} + {hasValidationFailures && ( + + {hasThreats && ( + + Validation Failures + + )} + + {scan.validationFailures.map((failure, index) => ( + + ))} + + + )} + + + ); +}; diff --git a/webui/src/components/scan-admin/scan-card/scan-card-header.tsx b/webui/src/components/scan-admin/scan-card/scan-card-header.tsx new file mode 100644 index 000000000..c71462530 --- /dev/null +++ b/webui/src/components/scan-admin/scan-card/scan-card-header.tsx @@ -0,0 +1,172 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import React from 'react'; +import { Box, Typography, Chip, CircularProgress } from '@mui/material'; +import { + CheckCircle as CheckCircleIcon, + GppMaybe as WarningIcon, + Block as BlockIcon, + Cancel as CancelIcon, + Info as InfoIcon, +} from '@mui/icons-material'; +import { ScanResult } from '../../../context/scan-admin'; +import { ConditionalTooltip } from '../common'; +import { useTheme } from '@mui/material/styles'; +import { + ICON_SIZE, + isRunning, + shouldShowStriped, + getHypotheticalStatus, + getStatusColorSx, +} from './utils'; + +interface ScanCardHeaderProps { + scan: ScanResult; +} + +const getStatusIcon = (status: ScanResult['status']) => { + switch (status) { + case 'PASSED': + return ; + case 'QUARANTINED': + return ; + case 'AUTO REJECTED': + return ; + case 'ERROR': + return ; + default: + return null; + } +}; + +/** + * Header section of the ScanCard containing: + * - Extension icon + * - Display name and namespace + * - Status badge + */ +export const ScanCardHeader: React.FC = ({ scan }) => { + const theme = useTheme(); + + return ( + <> + {/* Column 1: Icon */} + + {scan.extensionIcon ? ( + {scan.displayName} + ) : ( + + {scan.displayName.charAt(0).toUpperCase()} + + )} + + + {/* Columns 2-4: Display Name and Namespace */} + + + + {scan.displayName} + + + + + {scan.namespace}.{scan.extensionName} + + + + + {/* Column 5: Status Badge */} + + {isRunning(scan.status) ? ( + + ) : ( + <> + + {shouldShowStriped(scan) && getHypotheticalStatus(scan) && ( + + + + Would be {getHypotheticalStatus(scan)} + + + )} + + )} + + + ); +}; diff --git a/webui/src/components/scan-admin/scan-card/scan-card.tsx b/webui/src/components/scan-admin/scan-card/scan-card.tsx new file mode 100644 index 000000000..3920762cf --- /dev/null +++ b/webui/src/components/scan-admin/scan-card/scan-card.tsx @@ -0,0 +1,152 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import React, { FunctionComponent } from 'react'; +import { Card, CardContent, Box } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import { ScanResult } from '../../../context/scan-admin'; + +import { ScanCardHeader } from './scan-card-header'; +import { ScanCardContent } from './scan-card-content'; +import { ScanCardExpandStrip } from './scan-card-expand-strip'; +import { ScanCardExpandedContent } from './scan-card-expanded-content'; +import { useScanCardState } from '../../../hooks/scan-admin'; +import { + ICON_SIZE, + shouldShowStriped, + getStatusBarColor, +} from './utils'; + +interface ScanCardProps { + scan: ScanResult; + showCheckbox?: boolean; + onCheckboxChange?: (id: string, checked: boolean) => void; + checked?: boolean; +} + +/** + * ScanCard component displays information about an extension scan. + * + * Sub-components: + * - ScanCardHeader: Icon, display name, namespace, status badge + * - ScanCardContent: Publisher, version, download, scan times, checkbox + * - ScanCardExpandStrip: Collapsible trigger bar with badges + * - ScanCardExpandedContent: Threats and validation failures + * + * State is managed via the useScanCardState hook. + */ +export const ScanCard: FunctionComponent = ({ + scan, + showCheckbox, + onCheckboxChange, + checked, +}) => { + const theme = useTheme(); + const [collapseComplete, setCollapseComplete] = React.useState(true); + const { + expanded, + handleExpandClick, + showExpandButton, + badges, + liveDuration, + cardRef, + } = useScanCardState(scan); + + // Reset collapseComplete when expanding + React.useEffect(() => { + if (expanded) { + setCollapseComplete(false); + } + }, [expanded]); + + return ( + + + {/* 3 Row x 5 Column Grid Layout */} + + + + + + + {/* Expandable strip with badges */} + {showExpandButton && ( + + )} + + {/* Expanded content */} + {showExpandButton && ( + setCollapseComplete(true)} + /> + )} + + ); +}; diff --git a/webui/src/components/scan-admin/scan-card/scan-detail-card.tsx b/webui/src/components/scan-admin/scan-card/scan-detail-card.tsx new file mode 100644 index 000000000..91c3660d1 --- /dev/null +++ b/webui/src/components/scan-admin/scan-card/scan-detail-card.tsx @@ -0,0 +1,144 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import React, { FunctionComponent, ReactNode } from 'react'; +import { Box, Typography, Chip } from '@mui/material'; +import { Info as InfoIcon } from '@mui/icons-material'; +import { useTheme } from '@mui/material/styles'; + +interface DetailField { + label: string; + value: string | undefined; +} + +interface ScanDetailCardProps { + accentColor: string; + isUnenforced?: boolean; + chip?: { + label: string; + color: string; + textColor: string; + }; + description?: string; + descriptionColor?: string; + details: DetailField[]; + children?: ReactNode; +} + +/** + * A detail card component for displaying scan detail items like errors, + * threats, and validation failures. Provides styling with a colored + * left border, optional chip badge, and metadata fields. + */ +export const ScanDetailCard: FunctionComponent = ({ + accentColor, + isUnenforced = false, + chip, + description, + descriptionColor, + details, + children, +}) => { + const theme = useTheme(); + + return ( + + + {/* Chip and unenforced indicator */} + {(chip || isUnenforced) && ( + + {isUnenforced && ( + + + + Not enforced + + + )} + {chip && ( + + )} + + )} + + {/* Description */} + {description && ( + + {description} + + )} + + {/* Detail fields */} + {details.map((detail, index) => ( + detail.value && ( + + {detail.label}: {detail.value} + + ) + ))} + + {/* Additional content */} + {children} + + + ); +}; diff --git a/webui/src/components/scan-admin/scan-card/utils.ts b/webui/src/components/scan-admin/scan-card/utils.ts new file mode 100644 index 000000000..2a64253b3 --- /dev/null +++ b/webui/src/components/scan-admin/scan-card/utils.ts @@ -0,0 +1,186 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { ScanResult } from '../../../context/scan-admin'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface DetailBadge { + label: string; + isEnforced: boolean; + type: 'threat' | 'validation'; +} + +// ============================================================================ +// Constants +// ============================================================================ + +export const ICON_SIZE = 48; + +// ============================================================================ +// Status Utilities +// ============================================================================ + +export const isRunning = (status: ScanResult['status']): boolean => { + return status === 'STARTED' || status === 'VALIDATING' || status === 'SCANNING'; +}; + +/** + * Determines whether the scan card badge/strip should show the striped effect. + * Only shows striping when the hypothetical status would be DIFFERENT from the current status. + * + * - PASSED: Stripe if any threats or validationFailures exist (hypothetical would be QUARANTINED or AUTO REJECTED) + * - QUARANTINED: Stripe only if validationFailures exist (hypothetical would be AUTO REJECTED) + * - Unenforced threats don't cause striping since hypothetical would still be QUARANTINED + * - AUTO REJECTED: Never stripe (hypothetical would still be AUTO REJECTED) + */ +export const shouldShowStriped = (scan: ScanResult): boolean => { + switch (scan.status) { + case 'PASSED': + // Any issues mean hypothetical status would be different (QUARANTINED or AUTO REJECTED) + return scan.threats.length > 0 || scan.validationFailures.length > 0; + + case 'QUARANTINED': + // Only validation failures would change status to AUTO REJECTED + // Unenforced threats would still result in QUARANTINED (same status, no stripe) + return scan.validationFailures.length > 0; + + case 'AUTO REJECTED': + // Scan could only be AUTO REJECTED if there's at least one enforced validation failure + // Unenforced validation failures would still result in AUTO REJECTED (same status, no stripe) + return false; + + default: + return false; + } +}; + +export const getHypotheticalStatus = (scan: ScanResult): ScanResult['status'] | null => { + if (!shouldShowStriped(scan)) return null; + + const hasUnenforcedValidationFailures = scan.validationFailures.some(v => !v.enforcedFlag); + const hasUnenforcedThreats = scan.threats.some(t => !t.enforcedFlag); + + if (hasUnenforcedValidationFailures) { + return 'AUTO REJECTED'; + } + + if (hasUnenforcedThreats) { + return 'QUARANTINED'; + } + + return null; +}; + +export const getDetailBadges = (scan: ScanResult): DetailBadge[] => { + const badges: DetailBadge[] = []; + + scan.threats.forEach(threat => { + if (threat.type) { + badges.push({ + label: threat.type, + isEnforced: threat.enforcedFlag, + type: 'threat' + }); + } + }); + + scan.validationFailures.forEach(failure => { + badges.push({ + label: failure.type, + isEnforced: failure.enforcedFlag, + type: 'validation' + }); + }); + + return badges; +}; + +// ============================================================================ +// Theme Utilities +// ============================================================================ + +export const getStatusColorSx = (status: ScanResult['status'], theme: any) => { + switch (status) { + case 'PASSED': + return { + backgroundColor: theme.palette.passed.dark, + color: theme.palette.passed.light, + '& .MuiChip-icon': { + color: theme.palette.passed.light, + }, + }; + case 'QUARANTINED': + return { + backgroundColor: theme.palette.quarantined.dark, + color: theme.palette.quarantined.light, + '& .MuiChip-icon': { + color: theme.palette.quarantined.light, + }, + }; + case 'AUTO REJECTED': + return { + backgroundColor: theme.palette.rejected.dark, + color: theme.palette.rejected.light, + '& .MuiChip-icon': { + color: theme.palette.rejected.light, + }, + }; + case 'ERROR': + return { + backgroundColor: theme.palette.errorStatus.dark, + color: theme.palette.errorStatus.light, + '& .MuiChip-icon': { + color: theme.palette.errorStatus.light, + }, + }; + default: + return {}; + } +}; + +export const getStatusBarColor = (status: ScanResult['status'], theme: any) => { + switch (status) { + case 'PASSED': + return theme.palette.passed.dark; + case 'QUARANTINED': + return theme.palette.quarantined.dark; + case 'AUTO REJECTED': + return theme.palette.rejected.dark; + case 'ERROR': + return theme.palette.errorStatus.dark; + default: + return 'transparent'; + } +}; + +// ============================================================================ +// UI Utilities +// ============================================================================ + +export const shouldShowExpandButton = (scan: ScanResult): boolean => { + const hasErrorMessage = scan.status === 'ERROR' && !!scan.errorMessage; + return scan.threats.length > 0 || scan.validationFailures.length > 0 || hasErrorMessage; +}; + +export const hasDownload = (scan: ScanResult): boolean => { + return scan.status === 'PASSED' || scan.status === 'QUARANTINED'; +}; + +export const getFileName = (url?: string): string => { + if (!url) return 'extension.vsix'; + const parts = url.split('/'); + return parts[parts.length - 1] || 'extension.vsix'; +}; diff --git a/webui/src/components/scan-admin/tab-contents/allow-list-tab-content.tsx b/webui/src/components/scan-admin/tab-contents/allow-list-tab-content.tsx new file mode 100644 index 000000000..524caf5b9 --- /dev/null +++ b/webui/src/components/scan-admin/tab-contents/allow-list-tab-content.tsx @@ -0,0 +1,119 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import React, { FunctionComponent } from 'react'; +import { Box, Typography, Pagination, CircularProgress } from '@mui/material'; +import { SearchToolbar, CountsToolbar } from '../toolbars'; +import { FileTable, AutoRefresh } from '../common'; +import { useAllowListTab } from '../../../hooks/scan-admin'; +import { useScanContext } from '../../../context/scan-admin'; +import { useTheme } from '@mui/material/styles'; + +/** + * Allowed Files tab component that displays files that have been allowed. + * Uses the useAllowListTab hook to consume context. + */ +export const AllowListTabContent: FunctionComponent = () => { + const theme = useTheme(); + const { state, actions } = useScanContext(); + const { + files, + isLoading, + fileCount, + search, + selection, + setFilesChecked, + fileActions, + pagination, + lastRefreshed, + autoRefresh, + onAutoRefreshChange, + } = useAllowListTab(); + + return ( + <> + + + + + + {isLoading ? ( + + + + ) : files.length === 0 ? ( + + + No allowed files + + + Files that have been allowed will appear here + + + ) : ( + + )} + {pagination.totalPages > 1 && ( + + pagination.goToPage(page - 1)} + disabled={isLoading} + color='secondary' + size='large' + showFirstButton + showLastButton + /> + + )} + + ); +}; diff --git a/webui/src/components/scan-admin/tab-contents/auto-rejected-tab-content.tsx b/webui/src/components/scan-admin/tab-contents/auto-rejected-tab-content.tsx new file mode 100644 index 000000000..a6cb9b44a --- /dev/null +++ b/webui/src/components/scan-admin/tab-contents/auto-rejected-tab-content.tsx @@ -0,0 +1,106 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 +********************************************************************************/ + +import React, { FunctionComponent } from 'react'; +import { Box, Typography, Pagination, CircularProgress } from '@mui/material'; +import { ScanCard } from '../scan-card'; +import { SearchToolbar, CountsToolbar } from '../toolbars'; +import { AutoRefresh } from '../common'; +import { useAutoRejectedTab } from '../../../hooks/scan-admin'; + +/** + * Auto Rejected tab component that displays extensions that failed validation. + * Uses the useAutoRejectedTab hook to consume context. + */ +export const AutoRejectedTabContent: FunctionComponent = () => { + const { + scans, + isLoading, + lastRefreshed, + autoRefresh, + onAutoRefreshChange, + totalCount, + search, + globalFilters, + validationTypeFilters, + pagination, + hasValidationTypes, + } = useAutoRejectedTab(); + + return ( + <> + + + ({ + label: type, + value: type, + checked: validationTypeFilters.filters.has(type), + }))} + onFilterOptionToggle={validationTypeFilters.toggle} + dateRange={globalFilters.dateRange} + onDateRangeChange={globalFilters.setDateRange} + enforcement={globalFilters.enforcement} + onEnforcementChange={globalFilters.setEnforcement} + /> + + + {isLoading ? ( + + + + ) : (!hasValidationTypes || scans.length === 0) ? ( + + + No auto rejected extensions + + + Extensions that fail validation will appear here + + + ) : ( + scans.map((scan) => ( + + )) + )} + {pagination.totalPages > 1 && ( + + pagination.goToPage(page - 1)} + disabled={isLoading} + color='secondary' + size='large' + showFirstButton + showLastButton + /> + + )} + + ); +}; diff --git a/webui/src/components/scan-admin/tab-contents/block-list-tab-content.tsx b/webui/src/components/scan-admin/tab-contents/block-list-tab-content.tsx new file mode 100644 index 000000000..f6c79e23b --- /dev/null +++ b/webui/src/components/scan-admin/tab-contents/block-list-tab-content.tsx @@ -0,0 +1,119 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import React, { FunctionComponent } from 'react'; +import { Box, Typography, Pagination, CircularProgress } from '@mui/material'; +import { SearchToolbar, CountsToolbar } from '../toolbars'; +import { FileTable, AutoRefresh } from '../common'; +import { useBlockListTab } from '../../../hooks/scan-admin'; +import { useScanContext } from '../../../context/scan-admin'; +import { useTheme } from '@mui/material/styles'; + +/** + * Blocked Files tab component that displays files that have been blocked. + * Uses the useBlockListTab hook to consume context. + */ +export const BlockListTabContent: FunctionComponent = () => { + const theme = useTheme(); + const { state, actions } = useScanContext(); + const { + files, + isLoading, + fileCount, + search, + selection, + setFilesChecked, + fileActions, + pagination, + lastRefreshed, + autoRefresh, + onAutoRefreshChange, + } = useBlockListTab(); + + return ( + <> + + + + + + {isLoading ? ( + + + + ) : files.length === 0 ? ( + + + No blocked files + + + Files that have been blocked will appear here + + + ) : ( + + )} + {pagination.totalPages > 1 && ( + + pagination.goToPage(page - 1)} + disabled={isLoading} + color='secondary' + size='large' + showFirstButton + showLastButton + /> + + )} + + ); +}; diff --git a/webui/src/components/scan-admin/tab-contents/index.ts b/webui/src/components/scan-admin/tab-contents/index.ts new file mode 100644 index 000000000..876bbf617 --- /dev/null +++ b/webui/src/components/scan-admin/tab-contents/index.ts @@ -0,0 +1,18 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +export { ScansTabContent } from './scans-tab-content'; +export { QuarantinedTabContent } from './quarantined-tab-content'; +export { AutoRejectedTabContent } from './auto-rejected-tab-content'; +export { AllowListTabContent } from './allow-list-tab-content'; +export { BlockListTabContent } from './block-list-tab-content'; diff --git a/webui/src/components/scan-admin/tab-contents/quarantined-tab-content.tsx b/webui/src/components/scan-admin/tab-contents/quarantined-tab-content.tsx new file mode 100644 index 000000000..cd03f8220 --- /dev/null +++ b/webui/src/components/scan-admin/tab-contents/quarantined-tab-content.tsx @@ -0,0 +1,158 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import React, { FunctionComponent } from 'react'; +import { Box, Typography, Pagination, CircularProgress } from '@mui/material'; +import { ScanCard } from '../scan-card'; +import { SearchToolbar, CountsToolbar } from '../toolbars'; +import { AutoRefresh } from '../common'; +import { useQuarantinedTab } from '../../../hooks/scan-admin'; +import { useScanContext } from '../../../context/scan-admin'; +import { useTheme } from '@mui/material/styles'; + +/** + * Quarantined tab component that displays extensions flagged by security scans. + * Uses the useQuarantinedTab hook to consume context. + */ +export const QuarantinedTabContent: FunctionComponent = () => { + const theme = useTheme(); + const { state } = useScanContext(); + const { + scans, + isLoading, + lastRefreshed, + autoRefresh, + onAutoRefreshChange, + totalCount, + search, + globalFilters, + quarantineFilters, + pagination, + selection, + toggleCheck, + selectAll, + deselectAll, + isAllSelected, + bulkActions, + hasThreatScanners, + } = useQuarantinedTab(); + + // Calculate allowed/blocked/needs review counts from scanCounts + const allowedCount = state.scanCounts?.ALLOWED ?? 0; + const blockedCount = state.scanCounts?.BLOCKED ?? 0; + const needsReviewCount = state.scanCounts?.NEEDS_REVIEW ?? 0; + + const handleSelectAllChange = (checked: boolean) => { + if (checked) { + selectAll(); + } else { + deselectAll(); + } + }; + + return ( + <> + + + ({ + label: scanner, + value: scanner, + checked: quarantineFilters.threatScannerFilters.has(scanner), + }))} + onFilterOptionToggle={quarantineFilters.toggleThreatScanner} + dateRange={globalFilters.dateRange} + onDateRangeChange={globalFilters.setDateRange} + enforcement={globalFilters.enforcement} + onEnforcementChange={globalFilters.setEnforcement} + /> + + + {isLoading ? ( + + + + ) : (!hasThreatScanners || scans.length === 0) ? ( + + + No quarantined extensions + + + Extensions flagged by security scans will appear here + + + ) : ( + scans.map((scan) => { + // Only show checkbox for scans that need review: + // - No admin decision yet AND + // - Has at least one enforced threat (unenforced threats don't require review) + const hasEnforcedThreat = scan.threats.some(t => t.enforcedFlag); + const needsReview = !scan.adminDecision?.decision && hasEnforcedThreat; + return ( + + ); + }) + )} + {pagination.totalPages > 1 && ( + + pagination.goToPage(page - 1)} + disabled={isLoading} + color='secondary' + size='large' + showFirstButton + showLastButton + /> + + )} + + ); +}; diff --git a/webui/src/components/scan-admin/tab-contents/scans-tab-content.tsx b/webui/src/components/scan-admin/tab-contents/scans-tab-content.tsx new file mode 100644 index 000000000..5c9600738 --- /dev/null +++ b/webui/src/components/scan-admin/tab-contents/scans-tab-content.tsx @@ -0,0 +1,113 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import React, { FunctionComponent } from 'react'; +import { Box, Typography, Pagination, CircularProgress } from '@mui/material'; +import { ScanCard } from '../scan-card'; +import { SearchToolbar, CountsToolbar } from '../toolbars'; +import { AutoRefresh } from '../common'; +import { useScansTab } from '../../../hooks/scan-admin'; +import { useTheme } from '@mui/material/styles'; + +/** + * Scans tab component that displays an overview of all extension scans. + * Uses the useScansTab hook to consume context. + */ +export const ScansTabContent: FunctionComponent = () => { + const theme = useTheme(); + const { + scans, + isLoading, + lastRefreshed, + autoRefresh, + onAutoRefreshChange, + counts, + search, + globalFilters, + statusFilters, + pagination, + } = useScansTab(); + + return ( + <> + + + + + + {isLoading ? ( + + + + ) : scans.length === 0 ? ( + + + No scans found + + + {search.hasActiveSearch ? 'Try adjusting your search query' : 'Running and completed scans will appear here'} + + + ) : ( + scans.map((scan) => ( + + )) + )} + {pagination.totalPages > 1 && ( + + pagination.goToPage(page - 1)} + disabled={isLoading} + color='secondary' + size='large' + showFirstButton + showLastButton + /> + + )} + + ); +}; diff --git a/webui/src/components/scan-admin/toolbars/counts-toolbar.tsx b/webui/src/components/scan-admin/toolbars/counts-toolbar.tsx new file mode 100644 index 000000000..c63e24e86 --- /dev/null +++ b/webui/src/components/scan-admin/toolbars/counts-toolbar.tsx @@ -0,0 +1,262 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import React, { FunctionComponent, useState } from 'react'; +import { Box, Typography, Button, Checkbox, FormControlLabel, Select, MenuItem, SelectChangeEvent, ToggleButtonGroup, ToggleButton, Menu, Badge } from '@mui/material'; +import { FilterList as FilterListIcon } from '@mui/icons-material'; +import { useTheme } from '@mui/material/styles'; + +interface CountDisplay { + label: string; + value: number; + color?: string; +} + +interface FilterOption { + label: string; + value: string; + checked: boolean; +} + +interface CountsToolbarProps { + // Counts to display + counts: CountDisplay[]; + + // Optional filter button with menu + filterOptions?: FilterOption[]; + onFilterOptionToggle?: (value: string) => void; + + // Optional date range filter + dateRange?: 'today' | 'last7days' | 'last30days' | 'last90days' | 'all'; + onDateRangeChange?: (range: 'today' | 'last7days' | 'last30days' | 'last90days' | 'all') => void; + + // Optional enforcement filter + enforcement?: 'enforced' | 'notEnforced' | 'all'; + onEnforcementChange?: (enforcement: 'enforced' | 'notEnforced' | 'all') => void; +} + +export const CountsToolbar: FunctionComponent = ({ + counts, + filterOptions = [], + onFilterOptionToggle, + dateRange = 'all', + onDateRangeChange, + enforcement = 'all', + onEnforcementChange, +}) => { + const theme = useTheme(); + const [filterMenuAnchor, setFilterMenuAnchor] = useState(null); + + const filterMenuOpen = Boolean(filterMenuAnchor); + const activeFilterCount = filterOptions.filter(opt => opt.checked).length; + + if (counts.length === 0) { + return null; + } + + return ( + + {counts.map((count, index) => ( + + + {count.label}: + + + {count.value} + + + ))} + + {/* Right-aligned controls */} + + {/* Filter Button with Menu */} + {onFilterOptionToggle && ( + <> + + setFilterMenuAnchor(null)} + PaperProps={{ + sx: { + maxHeight: 400, + minWidth: 200, + backgroundColor: theme.palette.scanBackground.dark, + }, + }} + > + {filterOptions.map((option) => ( + onFilterOptionToggle(option.value)} + sx={{ + py: 0.5, + '&:hover': { + backgroundColor: theme.palette.scanBackground.default, + }, + }} + > + + } + label={ + + {option.label} + + } + sx={{ m: 0, width: '100%', pointerEvents: 'none' }} + /> + + ))} + + + )} + + {/* Enforcement Filter */} + {onEnforcementChange && ( + + value && onEnforcementChange(value)} + size='small' + sx={{ + height: '32px', + '& .MuiToggleButton-root': { + py: 0.5, + px: 1.5, + fontSize: '0.8125rem', + textTransform: 'none', + whiteSpace: 'nowrap', + borderColor: 'divider', + '&.Mui-selected': { + backgroundColor: theme.palette.secondary.main, + color: theme.palette.secondary.contrastText, + '&:hover': { + backgroundColor: theme.palette.secondary.dark, + }, + }, + }, + }} + > + Enforced + Not Enforced + All + + + )} + + {/* Date Range Filter */} + {onDateRangeChange && ( + + + Date Range: + + + + )} + + + ); +}; diff --git a/webui/src/components/scan-admin/toolbars/index.ts b/webui/src/components/scan-admin/toolbars/index.ts new file mode 100644 index 000000000..1ee024c27 --- /dev/null +++ b/webui/src/components/scan-admin/toolbars/index.ts @@ -0,0 +1,16 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +export { TabToolbar } from './tab-toolbar'; +export { SearchToolbar } from './search-toolbar'; +export { CountsToolbar } from './counts-toolbar'; diff --git a/webui/src/components/scan-admin/toolbars/search-toolbar.tsx b/webui/src/components/scan-admin/toolbars/search-toolbar.tsx new file mode 100644 index 000000000..5397ba8b1 --- /dev/null +++ b/webui/src/components/scan-admin/toolbars/search-toolbar.tsx @@ -0,0 +1,255 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import React, { FunctionComponent } from 'react'; +import { Box, Typography, TextField, InputAdornment, Button, Checkbox, FormControlLabel } from '@mui/material'; +import { CheckCircle as CheckCircleIcon, RadioButtonUnchecked as RadioButtonUncheckedIcon, PersonOutlined as PersonIcon, AccountTreeOutlined as AccountTreeIcon, ExtensionOutlined as ExtensionIcon } from '@mui/icons-material'; +import { useTheme } from '@mui/material/styles'; + +interface FilterItem { + label: string; + value: string; + checked: boolean; + onChange: (value: string) => void; + disabled?: boolean; +} + +interface ActionButton { + label: string; + color: string; + disabled: boolean; + onClick: () => void; +} + +interface SearchToolbarProps { + publisherQuery: string; + namespaceQuery: string; + nameQuery: string; + onPublisherChange: (event: React.ChangeEvent) => void; + onNamespaceChange: (event: React.ChangeEvent) => void; + onNameChange: (event: React.ChangeEvent) => void; + + // Optional inline filters + filters?: FilterItem[]; + + // Optional select all checkbox + showSelectAll?: boolean; + allSelected?: boolean; + onSelectAllChange?: (checked: boolean) => void; + + // Optional action buttons + actionButtons?: ActionButton[]; + + // Optional selected count display + selectedCount?: number; +} + +export const SearchToolbar: FunctionComponent = ({ + publisherQuery, + namespaceQuery, + nameQuery, + onPublisherChange, + onNamespaceChange, + onNameChange, + filters = [], + showSelectAll = false, + allSelected = false, + onSelectAllChange, + actionButtons = [], + selectedCount = 0, +}) => { + const theme = useTheme(); + + return ( + + + + + ), + }} + sx={{ + flex: 1, + minWidth: 100, + maxWidth: '16.67%', + '& .MuiOutlinedInput-root': { + backgroundColor: theme.palette.scanBackground.dark, + '&.Mui-focused fieldset': { + borderColor: theme.palette.secondary.main, + }, + }, + }} + /> + + + + ), + }} + sx={{ + flex: 1, + minWidth: 100, + maxWidth: '16.67%', + '& .MuiOutlinedInput-root': { + backgroundColor: theme.palette.scanBackground.dark, + '&.Mui-focused fieldset': { + borderColor: theme.palette.secondary.main, + }, + }, + }} + /> + + + + ), + }} + sx={{ + flex: 1, + minWidth: 100, + maxWidth: '16.67%', + '& .MuiOutlinedInput-root': { + backgroundColor: theme.palette.scanBackground.dark, + '&.Mui-focused fieldset': { + borderColor: theme.palette.secondary.main, + }, + }, + }} + /> + + {/* Inline Filter Checkboxes */} + {filters.length > 0 && ( + + {filters.map((filter) => ( + filter.onChange(filter.value)} + disabled={filter.disabled} + size='small' + sx={{ + color: 'rgba(255, 255, 255, 0.23)', + '&.Mui-checked': { + color: theme.palette.secondary.main, + }, + }} + /> + } + label={ + + {filter.label} + + } + sx={{ mr: 0 }} + disabled={filter.disabled} + /> + ))} + + )} + + {/* Select All and Action Buttons */} + + {showSelectAll && onSelectAllChange && ( + + )} + + + {/* Action Buttons */} + {actionButtons.length > 0 && ( + + + {actionButtons.map((button, index) => ( + + ))} + + {selectedCount > 0 && ( + + {selectedCount} selected + + )} + + )} + + ); +}; diff --git a/webui/src/components/scan-admin/toolbars/tab-toolbar.tsx b/webui/src/components/scan-admin/toolbars/tab-toolbar.tsx new file mode 100644 index 000000000..67b3aeacb --- /dev/null +++ b/webui/src/components/scan-admin/toolbars/tab-toolbar.tsx @@ -0,0 +1,56 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import React, { FunctionComponent } from 'react'; +import { Paper, Tabs, Tab } from '@mui/material'; + +interface TabToolbarProps { + selectedTab: number; + onTabChange: (event: React.SyntheticEvent, newValue: number) => void; + tabs: string[]; +} + +/** + * TabToolbar component for the scan admin tab navigation. + * Renders the Paper/Tabs bar for switching between scan tabs. + */ +export const TabToolbar: FunctionComponent = ({ + selectedTab, + onTabChange, + tabs, +}) => { + return ( + + + {tabs.map((label, index) => ( + + ))} + + + ); +}; diff --git a/webui/src/context/scan-admin/index.ts b/webui/src/context/scan-admin/index.ts new file mode 100644 index 000000000..ba169914f --- /dev/null +++ b/webui/src/context/scan-admin/index.ts @@ -0,0 +1,43 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +// Context and Provider +export { ScanProvider, useScanContext } from './scan-context'; + +// Context Types +export type { ScanContextValue, ScanActions, DerivedData, ScanProviderProps } from './scan-context-types'; + +// Domain Types +export * from './scan-types'; + +// Reducer +export { scanReducer } from './scan-reducer'; + +// Helpers +export { getDateRangeParams, getFileDateRange } from './scan-helpers'; + +// API Effects (for testing or advanced use cases) +export { + useFilterOptionsEffect, + useScansEffect, + useScanCountsEffect, + useFilesEffect, + useFileCountsEffect, + useAutoRefreshEffect, +} from './scan-api-effects'; + +// API Actions (for testing or advanced use cases) +export { useConfirmAction, useFileAction } from './scan-api-actions'; + +// Actions Factory (for testing or advanced use cases) +export { useScanActions } from './scan-actions'; diff --git a/webui/src/context/scan-admin/scan-actions.ts b/webui/src/context/scan-admin/scan-actions.ts new file mode 100644 index 000000000..5b53b3296 --- /dev/null +++ b/webui/src/context/scan-admin/scan-actions.ts @@ -0,0 +1,87 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { useMemo } from 'react'; +import { ScanAction, DateRangeType, EnforcementType, FileActionType, ScanResult } from './scan-types'; +import { ScanActions } from './scan-context-types'; + +// ============================================================================ +// Actions Factory Hook +// ============================================================================ + +/** + * Hook that creates memoized action creators for the scan context + */ +export const useScanActions = ( + dispatch: React.Dispatch, + executeConfirmAction: () => void, + executeFileAction: () => void +): ScanActions => { + return useMemo(() => ({ + // Tab + setTab: (tab: number) => dispatch({ type: 'SET_TAB', payload: tab }), + + // Search + setPublisherQuery: (query: string) => dispatch({ type: 'SET_PUBLISHER_QUERY', payload: query }), + setNamespaceQuery: (query: string) => dispatch({ type: 'SET_NAMESPACE_QUERY', payload: query }), + setNameQuery: (query: string) => dispatch({ type: 'SET_NAME_QUERY', payload: query }), + handlePublisherChange: (event: React.ChangeEvent) => + dispatch({ type: 'SET_PUBLISHER_QUERY', payload: event.target.value }), + handleNamespaceChange: (event: React.ChangeEvent) => + dispatch({ type: 'SET_NAMESPACE_QUERY', payload: event.target.value }), + handleNameChange: (event: React.ChangeEvent) => + dispatch({ type: 'SET_NAME_QUERY', payload: event.target.value }), + + // Pagination + setPage: (page: number) => dispatch({ type: 'SET_PAGE', payload: page }), + + // Filters + setDateRange: (range: DateRangeType) => dispatch({ type: 'SET_DATE_RANGE', payload: range }), + setEnforcement: (enforcement: EnforcementType) => dispatch({ type: 'SET_ENFORCEMENT', payload: enforcement }), + setAutoRefresh: (enabled: boolean) => dispatch({ type: 'SET_AUTO_REFRESH', payload: enabled }), + setFileDateRange: (range: DateRangeType) => dispatch({ type: 'SET_FILE_DATE_RANGE', payload: range }), + toggleStatusFilter: (status: string) => dispatch({ type: 'TOGGLE_STATUS_FILTER', payload: status }), + toggleQuarantineFilter: (filter: string) => dispatch({ type: 'TOGGLE_QUARANTINE_FILTER', payload: filter }), + toggleThreatScannerFilter: (scanner: string) => dispatch({ type: 'TOGGLE_THREAT_SCANNER_FILTER', payload: scanner }), + toggleValidationTypeFilter: (type: string) => dispatch({ type: 'TOGGLE_VALIDATION_TYPE_FILTER', payload: type }), + + // Menu anchors + openFilterMenu: (event: React.MouseEvent) => + dispatch({ type: 'SET_FILTER_MENU_ANCHOR', payload: event.currentTarget }), + closeFilterMenu: () => dispatch({ type: 'SET_FILTER_MENU_ANCHOR', payload: null }), + openQuarantineFilterMenu: (event: React.MouseEvent) => + dispatch({ type: 'SET_QUARANTINE_FILTER_MENU_ANCHOR', payload: event.currentTarget }), + closeQuarantineFilterMenu: () => dispatch({ type: 'SET_QUARANTINE_FILTER_MENU_ANCHOR', payload: null }), + openAutoRejectedFilterMenu: (event: React.MouseEvent) => + dispatch({ type: 'SET_AUTO_REJECTED_FILTER_MENU_ANCHOR', payload: event.currentTarget }), + closeAutoRejectedFilterMenu: () => dispatch({ type: 'SET_AUTO_REJECTED_FILTER_MENU_ANCHOR', payload: null }), + + // Selection + toggleQuarantinedCheck: (id: string, checked: boolean) => + dispatch({ type: 'TOGGLE_QUARANTINED_CHECKED', payload: { id, checked } }), + selectAllQuarantined: (scans: ScanResult[]) => + dispatch({ type: 'SELECT_ALL_QUARANTINED', payload: scans }), + deselectAllQuarantined: () => dispatch({ type: 'DESELECT_ALL_QUARANTINED' }), + setFilesChecked: (fileIds: Set) => + dispatch({ type: 'SET_FILES_CHECKED', payload: fileIds }), + + // Dialogs + openAllowDialog: () => dispatch({ type: 'OPEN_CONFIRM_DIALOG', payload: 'allow' }), + openBlockDialog: () => dispatch({ type: 'OPEN_CONFIRM_DIALOG', payload: 'block' }), + closeConfirmDialog: () => dispatch({ type: 'CLOSE_CONFIRM_DIALOG' }), + executeConfirmAction, + openFileDialog: (action: FileActionType) => dispatch({ type: 'OPEN_FILE_DIALOG', payload: action }), + closeFileDialog: () => dispatch({ type: 'CLOSE_FILE_DIALOG' }), + executeFileAction, + }), [dispatch, executeConfirmAction, executeFileAction]); +}; diff --git a/webui/src/context/scan-admin/scan-api-actions.ts b/webui/src/context/scan-admin/scan-api-actions.ts new file mode 100644 index 000000000..1ca739e85 --- /dev/null +++ b/webui/src/context/scan-admin/scan-api-actions.ts @@ -0,0 +1,137 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { useCallback } from 'react'; +import { ScanState, ScanAction } from './scan-types'; + +// ============================================================================ +// Confirm Action Hook +// ============================================================================ + +/** + * Hook that returns the async confirm action handler for allow/block operations + * on selected quarantined scans. Makes a single API call to persist the decision. + * The backend handles adding enforced threat files to the allow/block list. + */ +export const useConfirmAction = ( + service: any, + state: ScanState, + dispatch: React.Dispatch, + handleErrorRef: React.MutableRefObject<(error: any) => void> +) => { + return useCallback(async () => { + const abortController = new AbortController(); + + try { + // Get selected scan IDs + const selectedScanIds: string[] = []; + for (const id in state.quarantinedChecked) { + if (state.quarantinedChecked[id]) { + selectedScanIds.push(id); + } + } + + if (selectedScanIds.length === 0) { + dispatch({ type: 'CLOSE_CONFIRM_DIALOG' }); + return; + } + + const decision = state.confirmAction === 'allow' ? 'allowed' : 'blocked'; + + // Backend handles adding enforced threat files to allow/block list automatically + const scanResponse = await service.admin.makeScanDecision(abortController, { + scanIds: selectedScanIds, + decision, + }); + + if (scanResponse.error) { + handleErrorRef.current(new Error(scanResponse.error)); + return; + } + + // Update local state and trigger refresh + dispatch({ type: 'EXECUTE_CONFIRM_ACTION' }); + dispatch({ type: 'TRIGGER_REFRESH' }); + + } catch (err: any) { + if (!abortController.signal.aborted) { + handleErrorRef.current(err); + } + } + }, [service, state.quarantinedChecked, state.confirmAction, dispatch, handleErrorRef]); +}; + +// ============================================================================ +// File Action Hook +// ============================================================================ + +/** + * Hook that returns the async file action handler for allow/block/delete operations + * on selected files in Allowed/Blocked Files tabs. + */ +export const useFileAction = ( + service: any, + state: ScanState, + dispatch: React.Dispatch, + handleErrorRef: React.MutableRefObject<(error: any) => void> +) => { + return useCallback(async () => { + const abortController = new AbortController(); + + try { + if (state.filesChecked.size === 0) { + dispatch({ type: 'CLOSE_FILE_DIALOG' }); + return; + } + + const selectedFileIds = Array.from(state.filesChecked).map(id => parseInt(id, 10)); + + if (state.fileActionType === 'delete') { + const response = await service.admin.deleteFileDecisions(abortController, { + fileIds: selectedFileIds, + }); + + if (response.error) { + handleErrorRef.current(new Error(response.error)); + return; + } + } else { + const decision = state.fileActionType === 'allow' ? 'allowed' : 'blocked'; + + const selectedFiles = state.files.filter(file => state.filesChecked.has(file.id)); + const fileHashes = selectedFiles.map(file => file.fileHash); + + const response = await service.admin.makeFileDecision(abortController, { + fileHashes, + decision, + }); + + if (response.error) { + handleErrorRef.current(new Error(response.error)); + return; + } + } + + // Update local state and trigger refresh + dispatch({ type: 'SET_FILES_CHECKED', payload: new Set() }); + dispatch({ type: 'CLOSE_FILE_DIALOG' }); + dispatch({ type: 'RESET_PAGE' }); // Go back to first page after action + dispatch({ type: 'TRIGGER_REFRESH' }); + + } catch (err: any) { + if (!abortController.signal.aborted) { + handleErrorRef.current(err); + } + } + }, [service, state.filesChecked, state.fileActionType, state.files, dispatch, handleErrorRef]); +}; diff --git a/webui/src/context/scan-admin/scan-api-effects.ts b/webui/src/context/scan-admin/scan-api-effects.ts new file mode 100644 index 000000000..4812de1e0 --- /dev/null +++ b/webui/src/context/scan-admin/scan-api-effects.ts @@ -0,0 +1,479 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { useEffect } from 'react'; +import { ScanState, ScanAction, ScanResult } from './scan-types'; +import { getDateRangeParams, getFileDateRange } from './scan-helpers'; + +// ============================================================================ +// Filter Options Effect +// ============================================================================ + +/** + * Hook to fetch filter options (validation types, threat scanners) on mount and refresh + */ +export const useFilterOptionsEffect = ( + service: any, + state: ScanState, + dispatch: React.Dispatch, + handleErrorRef: React.MutableRefObject<(error: any) => void> +) => { + useEffect(() => { + const abortController = new AbortController(); + + const fetchFilterOptions = async () => { + try { + const options = await service.admin.getScanFilterOptions(abortController); + if (!abortController.signal.aborted) { + dispatch({ type: 'SET_AVAILABLE_VALIDATION_TYPES', payload: options.validationTypes || [] }); + dispatch({ type: 'SET_AVAILABLE_THREAT_SCANNERS', payload: options.threatScannerNames || [] }); + dispatch({ type: 'SET_FILTER_OPTIONS_LOADED', payload: true }); + } + } catch (err: any) { + if (!abortController.signal.aborted) { + // Even on error, mark as loaded so we don't block forever + dispatch({ type: 'SET_FILTER_OPTIONS_LOADED', payload: true }); + handleErrorRef.current(err); + } + } + }; + + fetchFilterOptions(); + return () => abortController.abort(); + }, [service, state.refreshTrigger, dispatch, handleErrorRef]); +}; + +// ============================================================================ +// Scans Effect +// ============================================================================ + +/** + * Hook to fetch scans from API (tabs 0, 1, 2: Scans, Quarantined, Auto Rejected) + */ +export const useScansEffect = ( + service: any, + state: ScanState, + dispatch: React.Dispatch, + handleErrorRef: React.MutableRefObject<(error: any) => void> +) => { + useEffect(() => { + // Only fetch scans for tabs 0, 1, 2 + if (state.selectedTab > 2) { + return; + } + + // Wait for filter options to be loaded to avoid duplicate requests + if (!state.filterOptionsLoaded) { + return; + } + + // For Quarantined tab, don't fetch if there are no threat scanners available + // (we only show scans with threats, so no threat scanners = no data to show) + if (state.selectedTab === 1 && state.availableThreatScanners.length === 0) { + dispatch({ type: 'SET_SCANS', payload: { scans: [], totalScans: 0 } }); + dispatch({ type: 'SET_LOADING_SCANS', payload: false }); + return; + } + + // For Auto Rejected tab, don't fetch if there are no validation types available + // (we only show scans with validation failures, so no validation types = no data to show) + if (state.selectedTab === 2 && state.availableValidationTypes.length === 0) { + dispatch({ type: 'SET_SCANS', payload: { scans: [], totalScans: 0 } }); + dispatch({ type: 'SET_LOADING_SCANS', payload: false }); + return; + } + + const abortController = new AbortController(); + + const fetchScans = async () => { + // Only show loading state if we don't have existing data to display + // This allows seamless background refreshes + if (state.scans.length === 0) { + dispatch({ type: 'SET_LOADING_SCANS', payload: true }); + } + try { + // Determine status filter based on selected tab and status filters + let statusParam: string[] | string | undefined; + let validationTypeParam: string[] | undefined; + let threatScannerParam: string[] | undefined; + let adminDecisionParam: string[] | undefined; + + if (state.selectedTab === 0) { + // Scans tab - use statusFilters, expanding 'running' to explicit statuses + statusParam = Array.from(state.statusFilters).reduce((result, status) => { + if (status === 'running') { + return [...result, 'STARTED', 'VALIDATING', 'SCANNING']; + } + return [...result, status]; + }, [] as string[]); + } else if (state.selectedTab === 1) { + // Quarantined tab - show scans with threats + // Filter by threat scanner (user selection or all available) + if (state.threatScannerFilters.size > 0) { + threatScannerParam = Array.from(state.threatScannerFilters); + } else { + threatScannerParam = state.availableThreatScanners; + } + // Filter by admin decision status (allowed/blocked/needs-review) + if (state.quarantineFilters.size > 0) { + adminDecisionParam = Array.from(state.quarantineFilters); + } + } else if (state.selectedTab === 2) { + // Auto Rejected tab - show scans with validation failures + // Filter by validation type (user selection or all available) + if (state.validationTypeFilters.size > 0) { + validationTypeParam = Array.from(state.validationTypeFilters); + } else { + validationTypeParam = state.availableValidationTypes; + } + } + + const dateParams = getDateRangeParams(state.dateRange); + + const response = await service.admin.getAllScans(abortController, { + size: state.pageSize, + offset: state.currentPage * state.pageSize, + publisher: state.publisherQuery || undefined, + namespace: state.namespaceQuery || undefined, + name: state.nameQuery || undefined, + status: statusParam, + validationType: validationTypeParam, + threatScannerName: threatScannerParam, + adminDecision: adminDecisionParam, + dateStartedFrom: dateParams.dateStartedFrom, + dateStartedTo: dateParams.dateStartedTo, + enforcement: state.enforcement + }); + + if (!abortController.signal.aborted) { + dispatch({ type: 'SET_LOADING_SCANS', payload: false }); + // Convert API response to ScanResult format + const convertedScans: ScanResult[] = response.scans.map((scan: any) => ({ + id: scan.id, + displayName: scan.displayName || scan.extensionName, + namespace: scan.namespace, + extensionName: scan.extensionName, + publisher: scan.publisher || '', + publisherUrl: scan.publisherUrl || null, + version: scan.version, + targetPlatform: scan.targetPlatform || 'universal', + universalTargetPlatform: scan.universalTargetPlatform ?? true, + status: scan.status as any, + dateScanStarted: scan.dateScanStarted, + dateScanEnded: scan.dateScanEnded || null, + dateQuarantined: scan.dateQuarantined || null, + dateRejected: scan.dateRejected || null, + adminDecision: scan.adminDecision ? { + decision: scan.adminDecision.decision, + decidedBy: scan.adminDecision.decidedBy, + dateDecided: scan.adminDecision.dateDecided, + } : null, + threats: (scan.threats || []).map((threat: any) => ({ + id: threat.id, + fileName: threat.fileName, + fileHash: threat.fileHash, + fileExtension: threat.fileExtension, + type: threat.type, + ruleName: threat.ruleName, + severity: threat.severity, + enforcedFlag: threat.enforcedFlag ?? true, + reason: threat.reason, + dateDetected: threat.dateDetected, + })), + validationFailures: (scan.validationFailures || []).map((failure: any) => ({ + id: failure.id, + type: failure.type, + ruleName: failure.ruleName, + reason: failure.reason, + dateDetected: failure.dateDetected, + enforcedFlag: failure.enforcedFlag ?? true, + })), + extensionIcon: scan.extensionIcon, + downloadUrl: scan.downloadUrl || null, + errorMessage: scan.errorMessage || null, + })); + + dispatch({ type: 'SET_SCANS', payload: { scans: convertedScans, totalScans: response.totalSize } }); + dispatch({ type: 'SET_LAST_REFRESHED', payload: new Date() }); + } + } catch (err: any) { + if (!abortController.signal.aborted) { + dispatch({ type: 'SET_LOADING_SCANS', payload: false }); + handleErrorRef.current(err); + } + } + }; + + fetchScans(); + return () => abortController.abort(); + }, [ + service, + state.filterOptionsLoaded, + state.currentPage, + state.pageSize, + state.publisherQuery, + state.namespaceQuery, + state.nameQuery, + state.selectedTab, + state.statusFilters, + state.quarantineFilters, + state.validationTypeFilters, + state.threatScannerFilters, + state.availableThreatScanners, + state.availableValidationTypes, + state.dateRange, + state.enforcement, + state.refreshTrigger, + state.scans.length, + dispatch, + handleErrorRef, + ]); +}; + +// ============================================================================ +// Scan Counts Effect +// ============================================================================ + +/** + * Hook to fetch scan counts from API + * Uses the same tab-aware filtering logic as the scans list + */ +export const useScanCountsEffect = ( + service: any, + state: ScanState, + dispatch: React.Dispatch, + handleErrorRef: React.MutableRefObject<(error: any) => void> +) => { + useEffect(() => { + const abortController = new AbortController(); + + const fetchCounts = async () => { + try { + const dateParams = getDateRangeParams(state.dateRange); + + // Determine filters based on selected tab (same logic as scans fetch) + let validationTypeParam: string[] | undefined; + let threatScannerParam: string[] | undefined; + + if (state.selectedTab === 0) { + // Scans tab - no threatScanner or validationType filters + // (same as scans endpoint which only uses statusFilters on this tab) + } else if (state.selectedTab === 1) { + // Quarantined tab - filter by threat scanner (user selection or all available) + if (state.threatScannerFilters.size > 0) { + threatScannerParam = Array.from(state.threatScannerFilters); + } else { + threatScannerParam = state.availableThreatScanners; + } + } else if (state.selectedTab === 2) { + // Auto Rejected tab - filter by validation type (user selection or all available) + if (state.validationTypeFilters.size > 0) { + validationTypeParam = Array.from(state.validationTypeFilters); + } else { + validationTypeParam = state.availableValidationTypes; + } + } + + const counts = await service.admin.getScanCounts(abortController, { + ...dateParams, + enforcement: state.enforcement, + threatScannerName: threatScannerParam, + validationType: validationTypeParam, + }); + + if (!abortController.signal.aborted) { + dispatch({ type: 'SET_SCAN_COUNTS', payload: counts }); + } + } catch (err: any) { + if (!abortController.signal.aborted) { + handleErrorRef.current(err); + } + } + }; + + fetchCounts(); + return () => abortController.abort(); + }, [ + service, + state.dateRange, + state.enforcement, + state.threatScannerFilters, + state.validationTypeFilters, + state.selectedTab, + state.availableThreatScanners, + state.availableValidationTypes, + state.refreshTrigger, + dispatch, + handleErrorRef, + ]); +}; + +// ============================================================================ +// Files Effect +// ============================================================================ + +/** + * Hook to fetch files from API (tabs 3, 4: Allowed Files, Blocked Files) + */ +export const useFilesEffect = ( + service: any, + state: ScanState, + dispatch: React.Dispatch, + handleErrorRef: React.MutableRefObject<(error: any) => void> +) => { + useEffect(() => { + // Only fetch files for tabs 3 and 4 + if (state.selectedTab < 3) { + return; + } + + const abortController = new AbortController(); + + const fetchFiles = async () => { + // Only show loading state if we don't have existing data to display + // (Avoids flashing loading indicator when auto-refreshing) + if (state.files.length === 0) { + dispatch({ type: 'SET_LOADING_FILES', payload: true }); + } + try { + // Determine decision filter based on selected tab + const decisionParam = state.selectedTab === 3 ? 'allowed' : 'blocked'; + + // Get date range parameters + const dateParams = getFileDateRange(state.fileDateRange); + + const response = await service.admin.getFiles(abortController, { + size: state.pageSize, + offset: state.currentPage * state.pageSize, + publisher: state.publisherQuery || undefined, + namespace: state.namespaceQuery || undefined, + name: state.nameQuery || undefined, + decision: decisionParam, + dateDecidedFrom: dateParams.dateDecidedFrom, + dateDecidedTo: dateParams.dateDecidedTo, + }); + + if (!abortController.signal.aborted) { + dispatch({ type: 'SET_LOADING_FILES', payload: false }); + dispatch({ type: 'SET_FILES', payload: { files: response.files, totalFiles: response.totalSize } }); + dispatch({ type: 'SET_LAST_REFRESHED', payload: new Date() }); + } + } catch (err: any) { + if (!abortController.signal.aborted) { + dispatch({ type: 'SET_LOADING_FILES', payload: false }); + handleErrorRef.current(err); + } + } + }; + + fetchFiles(); + return () => abortController.abort(); + }, [ + service, + state.currentPage, + state.pageSize, + state.publisherQuery, + state.namespaceQuery, + state.nameQuery, + state.selectedTab, + state.fileDateRange, + state.refreshTrigger, + state.files.length, + dispatch, + handleErrorRef, + ]); +}; + +// ============================================================================ +// File Counts Effect +// ============================================================================ + +/** + * Hook to fetch file counts from API + */ +export const useFileCountsEffect = ( + service: any, + state: ScanState, + dispatch: React.Dispatch, + handleErrorRef: React.MutableRefObject<(error: any) => void> +) => { + useEffect(() => { + // Only fetch file counts when on file tabs + if (state.selectedTab < 3) { + return; + } + + const abortController = new AbortController(); + + const fetchFileCounts = async () => { + try { + // Get date range parameters + const dateParams = getFileDateRange(state.fileDateRange); + + const counts = await service.admin.getFileCounts(abortController, { + dateDecidedFrom: dateParams.dateDecidedFrom, + dateDecidedTo: dateParams.dateDecidedTo, + }); + + if (!abortController.signal.aborted) { + dispatch({ type: 'SET_FILE_COUNTS', payload: counts }); + } + } catch (err: any) { + if (!abortController.signal.aborted) { + handleErrorRef.current(err); + } + } + }; + + fetchFileCounts(); + return () => abortController.abort(); + }, [ + service, + state.selectedTab, + state.fileDateRange, + state.refreshTrigger, + dispatch, + handleErrorRef, + ]); +}; + +// ============================================================================ +// Auto Refresh Effect +// ============================================================================ + +/** + * Hook for periodic refresh - refreshes data every 30 seconds when enabled and page is visible + */ +export const useAutoRefreshEffect = ( + state: ScanState, + dispatch: React.Dispatch +) => { + useEffect(() => { + if (!state.autoRefresh) { + return; // Don't set up interval if auto-refresh is disabled + } + + const REFRESH_INTERVAL = 30000; // 30 seconds + + const intervalId = setInterval(() => { + // Only refresh when page is visible + if (!document.hidden) { + dispatch({ type: 'TRIGGER_REFRESH' }); + } + }, REFRESH_INTERVAL); + + return () => { + clearInterval(intervalId); + }; + }, [state.autoRefresh, dispatch]); +}; diff --git a/webui/src/context/scan-admin/scan-context-types.ts b/webui/src/context/scan-admin/scan-context-types.ts new file mode 100644 index 000000000..c1a6028f6 --- /dev/null +++ b/webui/src/context/scan-admin/scan-context-types.ts @@ -0,0 +1,102 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import React from 'react'; +import { + ScanState, + ScanAction, + DateRangeType, + EnforcementType, + FileActionType, + FileDecision, + ScanResult, +} from './scan-types'; + +// ============================================================================ +// Context Types +// ============================================================================ + +export interface ScanContextValue { + state: ScanState; + dispatch: React.Dispatch; + actions: ScanActions; + derived: DerivedData; +} + +export interface ScanActions { + // Tab + setTab: (tab: number) => void; + + // Search + setPublisherQuery: (query: string) => void; + setNamespaceQuery: (query: string) => void; + setNameQuery: (query: string) => void; + handlePublisherChange: (event: React.ChangeEvent) => void; + handleNamespaceChange: (event: React.ChangeEvent) => void; + handleNameChange: (event: React.ChangeEvent) => void; + + // Pagination + setPage: (page: number) => void; + + // Filters + setDateRange: (range: DateRangeType) => void; + setEnforcement: (enforcement: EnforcementType) => void; + setAutoRefresh: (enabled: boolean) => void; + setFileDateRange: (range: DateRangeType) => void; + toggleStatusFilter: (status: string) => void; + toggleQuarantineFilter: (filter: string) => void; + toggleThreatScannerFilter: (scanner: string) => void; + toggleValidationTypeFilter: (type: string) => void; + + // Menu anchors + openFilterMenu: (event: React.MouseEvent) => void; + closeFilterMenu: () => void; + openQuarantineFilterMenu: (event: React.MouseEvent) => void; + closeQuarantineFilterMenu: () => void; + openAutoRejectedFilterMenu: (event: React.MouseEvent) => void; + closeAutoRejectedFilterMenu: () => void; + + // Selection + toggleQuarantinedCheck: (id: string, checked: boolean) => void; + selectAllQuarantined: (scans: ScanResult[]) => void; + deselectAllQuarantined: () => void; + setFilesChecked: (fileIds: Set) => void; + + // Dialogs + openAllowDialog: () => void; + openBlockDialog: () => void; + closeConfirmDialog: () => void; + executeConfirmAction: () => void; + openFileDialog: (action: FileActionType) => void; + closeFileDialog: () => void; + executeFileAction: () => void; +} + +export interface DerivedData { + /** Selected quarantined extensions (for bulk allow/block actions) */ + selectedExtensions: ScanResult[]; + /** Selected files from Allowed/Blocked Files tabs */ + selectedFiles: FileDecision[]; + /** Total pages for current data set (scans or files depending on tab) */ + totalPages: number; +} + +// ============================================================================ +// Provider Props +// ============================================================================ + +export interface ScanProviderProps { + children: React.ReactNode; + service: any; + handleError: (error: any) => void; +} diff --git a/webui/src/context/scan-admin/scan-context.tsx b/webui/src/context/scan-admin/scan-context.tsx new file mode 100644 index 000000000..43dd8fec7 --- /dev/null +++ b/webui/src/context/scan-admin/scan-context.tsx @@ -0,0 +1,123 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import React, { createContext, useContext, useReducer, useMemo, useRef } from 'react'; +import { initialScanState, ScanResult, FileDecision } from './scan-types'; +import { scanReducer } from './scan-reducer'; +import { ScanContextValue, ScanProviderProps, DerivedData } from './scan-context-types'; +import { + useFilterOptionsEffect, + useScansEffect, + useScanCountsEffect, + useFilesEffect, + useFileCountsEffect, + useAutoRefreshEffect, +} from './scan-api-effects'; +import { useConfirmAction, useFileAction } from './scan-api-actions'; +import { useScanActions } from './scan-actions'; + +// ============================================================================ +// Context Creation +// ============================================================================ + +const ScanContext = createContext(null); + +// ============================================================================ +// Provider Component +// ============================================================================ + +export const ScanProvider: React.FC = ({ children, service, handleError }) => { + const [state, dispatch] = useReducer(scanReducer, initialScanState); + + // Use a ref for handleError to avoid re-running effects when parent re-renders + // This prevents infinite loops when errors trigger parent state changes + const handleErrorRef = useRef(handleError); + handleErrorRef.current = handleError; + + // ======================================================================== + // API Effects + // ======================================================================== + + useFilterOptionsEffect(service, state, dispatch, handleErrorRef); + useScansEffect(service, state, dispatch, handleErrorRef); + useScanCountsEffect(service, state, dispatch, handleErrorRef); + useFilesEffect(service, state, dispatch, handleErrorRef); + useFileCountsEffect(service, state, dispatch, handleErrorRef); + useAutoRefreshEffect(state, dispatch); + + // ======================================================================== + // Async API Actions + // ======================================================================== + + const executeConfirmAction = useConfirmAction(service, state, dispatch, handleErrorRef); + const executeFileAction = useFileAction(service, state, dispatch, handleErrorRef); + + // ======================================================================== + // Actions + // ======================================================================== + + const actions = useScanActions(dispatch, executeConfirmAction, executeFileAction); + + // ======================================================================== + // Derived Data (memoized) + // ======================================================================== + + const selectedExtensions = useMemo((): ScanResult[] => { + return state.scans.filter(scan => state.quarantinedChecked[scan.id]); + }, [state.scans, state.quarantinedChecked]); + + const selectedFiles = useMemo((): FileDecision[] => { + return state.files.filter(file => state.filesChecked.has(file.id)); + }, [state.files, state.filesChecked]); + + const totalPages = useMemo(() => { + // For tabs 0-2 (scans), use totalScans; for tabs 3-4 (files), use totalFiles + const total = state.selectedTab >= 3 ? state.totalFiles : state.totalScans; + return Math.ceil(total / state.pageSize); + }, [state.selectedTab, state.totalScans, state.totalFiles, state.pageSize]); + + const derived: DerivedData = useMemo(() => ({ + selectedExtensions, + selectedFiles, + totalPages, + }), [selectedExtensions, selectedFiles, totalPages]); + + // ======================================================================== + // Context Value + // ======================================================================== + + const contextValue = useMemo(() => ({ + state, + dispatch, + actions, + derived, + }), [state, actions, derived]); + + return ( + + {children} + + ); +}; + +// ============================================================================ +// Custom Hook +// ============================================================================ + +export const useScanContext = (): ScanContextValue => { + const context = useContext(ScanContext); + if (!context) { + throw new Error('useScanContext must be used within a ScanProvider'); + } + return context; +}; diff --git a/webui/src/context/scan-admin/scan-helpers.ts b/webui/src/context/scan-admin/scan-helpers.ts new file mode 100644 index 000000000..e181df2b8 --- /dev/null +++ b/webui/src/context/scan-admin/scan-helpers.ts @@ -0,0 +1,63 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { DateRangeType } from './scan-types'; + +/** + * Calculates date range boundaries based on the date range type + * @returns { from, to } ISO date strings, or { from: undefined, to: undefined } for 'all' + */ +const calculateDateRange = (dateRange: DateRangeType): { from: string | undefined; to: string | undefined } => { + if (dateRange === 'all') { + return { from: undefined, to: undefined }; + } + + const now = new Date(); + const to = now.toISOString(); + let from: string; + + switch (dateRange) { + case 'today': + from = new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString(); + break; + case 'last7days': + from = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(); + break; + case 'last30days': + from = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(); + break; + case 'last90days': + from = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString(); + break; + default: + from = new Date(0).toISOString(); + } + + return { from, to }; +}; + +/** + * Converts a date range string to API date parameters for scans + */ +export const getDateRangeParams = (dateRange: DateRangeType) => { + const { from, to } = calculateDateRange(dateRange); + return { dateStartedFrom: from, dateStartedTo: to }; +}; + +/** + * Converts a date range string to API date parameters for file decisions + */ +export const getFileDateRange = (dateRange: DateRangeType) => { + const { from, to } = calculateDateRange(dateRange); + return { dateDecidedFrom: from, dateDecidedTo: to }; +}; diff --git a/webui/src/context/scan-admin/scan-reducer.ts b/webui/src/context/scan-admin/scan-reducer.ts new file mode 100644 index 000000000..fe1c1f096 --- /dev/null +++ b/webui/src/context/scan-admin/scan-reducer.ts @@ -0,0 +1,321 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { ScanState, ScanAction } from './scan-types'; + +/** + * Helper function to toggle a value in a Set + */ +const toggleSetValue = (set: Set, value: T): Set => { + const newSet = new Set(set); + if (newSet.has(value)) { + newSet.delete(value); + } else { + newSet.add(value); + } + return newSet; +}; + +/** + * Reducer for scan state management + */ +export const scanReducer = (state: ScanState, action: ScanAction): ScanState => { + switch (action.type) { + // ============================================================ + // Tab actions + // ============================================================ + case 'SET_TAB': { + const tab = action.payload; + // Tab-specific default enforcement: + // - Tabs 1 (Quarantined) and 2 (Auto Rejected): default to 'enforced' + // - Other tabs: default to 'all' + const defaultEnforcement = (tab === 1 || tab === 2) ? 'enforced' : 'all'; + return { + ...state, + selectedTab: tab, + // Clear search when switching tabs + publisherQuery: '', + namespaceQuery: '', + nameQuery: '', + // Reset page + currentPage: 0, + // Reset to tab-specific default filters + dateRange: 'all', + enforcement: defaultEnforcement, + quarantineFilters: new Set(), + statusFilters: new Set(), + // Clear scans and show loading state to prevent stale data flash + scans: [], + isLoadingScans: true, + // Clear files and show loading state to prevent stale data flash + files: [], + isLoadingFiles: tab >= 3, // Set loading state for file tabs (3, 4) + // Clear counts to prevent stale counts from showing + scanCounts: null, + fileCounts: null, + // Clear selection state to prevent stale counts + quarantinedChecked: {}, + filesChecked: new Set(), + }; + } + + // ============================================================ + // Search actions + // ============================================================ + case 'SET_PUBLISHER_QUERY': + return { ...state, publisherQuery: action.payload, currentPage: 0, quarantinedChecked: {}, filesChecked: new Set() }; + + case 'SET_NAMESPACE_QUERY': + return { ...state, namespaceQuery: action.payload, currentPage: 0, quarantinedChecked: {}, filesChecked: new Set() }; + + case 'SET_NAME_QUERY': + return { ...state, nameQuery: action.payload, currentPage: 0, quarantinedChecked: {}, filesChecked: new Set() }; + + case 'CLEAR_SEARCH': + return { + ...state, + publisherQuery: '', + namespaceQuery: '', + nameQuery: '', + filesChecked: new Set(), + }; + + // ============================================================ + // Pagination actions + // ============================================================ + case 'SET_PAGE': + return { ...state, currentPage: action.payload, quarantinedChecked: {}, filesChecked: new Set() }; + + case 'RESET_PAGE': + return { ...state, currentPage: 0 }; + + // ============================================================ + // Global filter actions + // ============================================================ + case 'SET_DATE_RANGE': + return { ...state, dateRange: action.payload, currentPage: 0, quarantinedChecked: {}, filesChecked: new Set() }; + + case 'SET_ENFORCEMENT': + return { + ...state, + enforcement: action.payload, + currentPage: 0, + quarantinedChecked: {}, + }; + + case 'SET_FILE_DATE_RANGE': + return { ...state, fileDateRange: action.payload, currentPage: 0, filesChecked: new Set() }; + + // ============================================================ + // Tab-specific filter actions + // ============================================================ + case 'TOGGLE_STATUS_FILTER': + return { + ...state, + statusFilters: toggleSetValue(state.statusFilters, action.payload), + currentPage: 0, + }; + + case 'SET_STATUS_FILTERS': + return { ...state, statusFilters: action.payload, currentPage: 0 }; + + case 'TOGGLE_QUARANTINE_FILTER': + return { + ...state, + quarantineFilters: toggleSetValue(state.quarantineFilters, action.payload), + currentPage: 0, + quarantinedChecked: {}, + }; + + case 'SET_QUARANTINE_FILTERS': + return { ...state, quarantineFilters: action.payload, currentPage: 0, quarantinedChecked: {} }; + + case 'TOGGLE_THREAT_SCANNER_FILTER': + return { + ...state, + threatScannerFilters: toggleSetValue(state.threatScannerFilters, action.payload), + currentPage: 0, + quarantinedChecked: {}, + }; + + case 'SET_THREAT_SCANNER_FILTERS': + return { ...state, threatScannerFilters: action.payload, currentPage: 0, quarantinedChecked: {} }; + + case 'TOGGLE_VALIDATION_TYPE_FILTER': + return { + ...state, + validationTypeFilters: toggleSetValue(state.validationTypeFilters, action.payload), + currentPage: 0, + }; + + case 'SET_VALIDATION_TYPE_FILTERS': + return { ...state, validationTypeFilters: action.payload, currentPage: 0 }; + + // ============================================================ + // Filter options actions (from API) + // ============================================================ + case 'SET_FILTER_OPTIONS_LOADED': + return { ...state, filterOptionsLoaded: action.payload }; + + case 'SET_AVAILABLE_VALIDATION_TYPES': + return { ...state, availableValidationTypes: action.payload }; + + case 'SET_AVAILABLE_THREAT_SCANNERS': + return { ...state, availableThreatScanners: action.payload }; + + // ============================================================ + // Menu anchor actions + // ============================================================ + case 'SET_FILTER_MENU_ANCHOR': + return { ...state, filterMenuAnchor: action.payload }; + + case 'SET_QUARANTINE_FILTER_MENU_ANCHOR': + return { ...state, quarantineFilterMenuAnchor: action.payload }; + + case 'SET_AUTO_REJECTED_FILTER_MENU_ANCHOR': + return { ...state, autoRejectedFilterMenuAnchor: action.payload }; + + // ============================================================ + // Selection actions + // ============================================================ + case 'SET_QUARANTINED_CHECKED': + return { ...state, quarantinedChecked: action.payload }; + + case 'TOGGLE_QUARANTINED_CHECKED': + return { + ...state, + quarantinedChecked: { + ...state.quarantinedChecked, + [action.payload.id]: action.payload.checked, + }, + }; + + case 'SELECT_ALL_QUARANTINED': { + const newChecked: Record = {}; + action.payload.forEach(scan => { + newChecked[scan.id] = true; + }); + return { ...state, quarantinedChecked: newChecked }; + } + + case 'DESELECT_ALL_QUARANTINED': + return { ...state, quarantinedChecked: {} }; + + case 'SET_SCAN_DECISIONS': + return { ...state, scanDecisions: action.payload }; + + case 'SET_FILES_CHECKED': + return { ...state, filesChecked: action.payload }; + + // ============================================================ + // Dialog actions + // ============================================================ + case 'OPEN_CONFIRM_DIALOG': + return { + ...state, + confirmDialogOpen: true, + confirmAction: action.payload, + }; + + case 'CLOSE_CONFIRM_DIALOG': + return { + ...state, + confirmDialogOpen: false, + confirmAction: null, + }; + + case 'OPEN_FILE_DIALOG': + return { + ...state, + fileDialogOpen: true, + fileActionType: action.payload, + }; + + case 'CLOSE_FILE_DIALOG': + return { + ...state, + fileDialogOpen: false, + fileActionType: null, + }; + + // ============================================================ + // Data actions (from API) + // ============================================================ + case 'SET_SCANS': + return { + ...state, + scans: action.payload.scans, + totalScans: action.payload.totalScans, + }; + + case 'SET_LOADING_SCANS': + return { ...state, isLoadingScans: action.payload }; + + case 'SET_SCAN_COUNTS': + return { ...state, scanCounts: action.payload }; + + case 'TRIGGER_REFRESH': + return { ...state, refreshTrigger: state.refreshTrigger + 1 }; + + case 'SET_LAST_REFRESHED': + return { ...state, lastRefreshed: action.payload }; + + case 'SET_AUTO_REFRESH': + return { ...state, autoRefresh: action.payload }; + + // ============================================================ + // File data actions (from /files API) + // ============================================================ + case 'SET_FILES': + return { + ...state, + files: action.payload.files, + totalFiles: action.payload.totalFiles, + }; + + case 'SET_LOADING_FILES': + return { ...state, isLoadingFiles: action.payload }; + + case 'SET_FILE_COUNTS': + return { ...state, fileCounts: action.payload }; + + // ============================================================ + // Confirm action execution + // ============================================================ + case 'EXECUTE_CONFIRM_ACTION': { + if (state.confirmAction === 'allow' || state.confirmAction === 'block') { + const newDecisions = { ...state.scanDecisions }; + for (const id in state.quarantinedChecked) { + if (state.quarantinedChecked[id]) { + newDecisions[id] = state.confirmAction === 'allow' ? 'allowed' : 'blocked'; + } + } + return { + ...state, + scanDecisions: newDecisions, + quarantinedChecked: {}, + confirmDialogOpen: false, + confirmAction: null, + }; + } + return { + ...state, + confirmDialogOpen: false, + confirmAction: null, + }; + } + + default: + return state; + } +}; diff --git a/webui/src/context/scan-admin/scan-types.ts b/webui/src/context/scan-admin/scan-types.ts new file mode 100644 index 000000000..9181581a8 --- /dev/null +++ b/webui/src/context/scan-admin/scan-types.ts @@ -0,0 +1,321 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +// ============================================================================ +// Domain Types +// ============================================================================ + +export type ScanStatus = 'STARTED' | 'VALIDATING' | 'SCANNING' | 'PASSED' | 'QUARANTINED' | 'AUTO REJECTED' | 'ERROR'; + +export interface ValidationFailure { + id: string; + type: string; + ruleName: string; + reason: string; + dateDetected: string; + enforcedFlag: boolean; +} + +export interface Threat { + id: string; + fileName: string; + fileHash: string; + fileExtension: string; + type: string; + ruleName: string; + severity?: string; + enforcedFlag: boolean; + reason: string; + dateDetected: string; +} + +export interface AdminDecision { + decision: string; + decidedBy: string; + dateDecided: string; +} + +export interface ScanResult { + id: string; + displayName: string; + namespace: string; + extensionName: string; + publisher: string; + publisherUrl: string | null; + version: string; + targetPlatform: string; + universalTargetPlatform: boolean; + status: ScanStatus; + dateScanStarted: string; + dateScanEnded: string | null; + dateQuarantined: string | null; + dateRejected: string | null; + adminDecision: AdminDecision | null; + threats: Threat[]; + validationFailures: ValidationFailure[]; + extensionIcon?: string; + downloadUrl: string | null; + errorMessage: string | null; +} + +// ============================================================================ +// State Types +// ============================================================================ + +export type DateRangeType = 'today' | 'last7days' | 'last30days' | 'last90days' | 'all'; +export type EnforcementType = 'enforced' | 'notEnforced' | 'all'; +export type ConfirmActionType = 'allow' | 'block' | 'delete' | null; +export type FileActionType = 'allow' | 'block' | 'delete' | null; +export type FileDecisionType = string; + +export interface ScanCounts { + STARTED: number; + VALIDATING: number; + SCANNING: number; + PASSED: number; + QUARANTINED: number; + AUTO_REJECTED: number; + ERROR: number; + ALLOWED: number; + BLOCKED: number; + NEEDS_REVIEW: number; +} + +/** + * Unified file decision type for the /files API + * Represents a file that has been allowed or blocked by an admin + */ +export interface FileDecision { + id: string; + fileName: string; + fileHash: string; + fileType: string; + decision: FileDecisionType; + decidedBy: string; + dateDecided: string; + displayName: string; + namespace: string; + extensionName: string; + publisher: string; + version: string; + scanId?: string; +} + +export interface FileDecisionCounts { + allowed: number; + blocked: number; + total: number; +} + +export interface ScanState { + // Tab state + selectedTab: number; + + // Search state + publisherQuery: string; + namespaceQuery: string; + nameQuery: string; + + // Pagination state + currentPage: number; + pageSize: number; + + // Global filter state + dateRange: DateRangeType; + enforcement: EnforcementType; + + // File-specific filter state + fileDateRange: DateRangeType; + + // Tab-specific filter state + statusFilters: Set; + quarantineFilters: Set; + threatScannerFilters: Set; + validationTypeFilters: Set; + + // Available filter options (from API) + filterOptionsLoaded: boolean; + availableValidationTypes: string[]; + availableThreatScanners: string[]; + + // Menu anchor state + filterMenuAnchor: HTMLElement | null; + quarantineFilterMenuAnchor: HTMLElement | null; + autoRejectedFilterMenuAnchor: HTMLElement | null; + + // Selection state + quarantinedChecked: Record; + scanDecisions: Record; + filesChecked: Set; + + // Dialog state + confirmDialogOpen: boolean; + confirmAction: ConfirmActionType; + fileDialogOpen: boolean; + fileActionType: FileActionType; + + // Scan data state (from /scans API) + scans: ScanResult[]; + totalScans: number; + isLoadingScans: boolean; + scanCounts: ScanCounts | null; + refreshTrigger: number; + lastRefreshed: Date | null; + autoRefresh: boolean; + + // File data state (from /files API) - for Allowed Files and Blocked Files tabs + files: FileDecision[]; + totalFiles: number; + isLoadingFiles: boolean; + fileCounts: FileDecisionCounts | null; +} + +// ============================================================================ +// Action Types +// ============================================================================ + +export type ScanAction = + // Tab actions + | { type: 'SET_TAB'; payload: number } + + // Search actions + | { type: 'SET_PUBLISHER_QUERY'; payload: string } + | { type: 'SET_NAMESPACE_QUERY'; payload: string } + | { type: 'SET_NAME_QUERY'; payload: string } + | { type: 'CLEAR_SEARCH' } + + // Pagination actions + | { type: 'SET_PAGE'; payload: number } + | { type: 'RESET_PAGE' } + + // Global filter actions + | { type: 'SET_DATE_RANGE'; payload: DateRangeType } + | { type: 'SET_ENFORCEMENT'; payload: EnforcementType } + | { type: 'SET_FILE_DATE_RANGE'; payload: DateRangeType } + + // Tab-specific filter actions + | { type: 'TOGGLE_STATUS_FILTER'; payload: string } + | { type: 'SET_STATUS_FILTERS'; payload: Set } + | { type: 'TOGGLE_QUARANTINE_FILTER'; payload: string } + | { type: 'SET_QUARANTINE_FILTERS'; payload: Set } + | { type: 'TOGGLE_THREAT_SCANNER_FILTER'; payload: string } + | { type: 'SET_THREAT_SCANNER_FILTERS'; payload: Set } + | { type: 'TOGGLE_VALIDATION_TYPE_FILTER'; payload: string } + | { type: 'SET_VALIDATION_TYPE_FILTERS'; payload: Set } + + // Filter options actions (from API) + | { type: 'SET_FILTER_OPTIONS_LOADED'; payload: boolean } + | { type: 'SET_AVAILABLE_VALIDATION_TYPES'; payload: string[] } + | { type: 'SET_AVAILABLE_THREAT_SCANNERS'; payload: string[] } + + // Menu anchor actions + | { type: 'SET_FILTER_MENU_ANCHOR'; payload: HTMLElement | null } + | { type: 'SET_QUARANTINE_FILTER_MENU_ANCHOR'; payload: HTMLElement | null } + | { type: 'SET_AUTO_REJECTED_FILTER_MENU_ANCHOR'; payload: HTMLElement | null } + + // Selection actions + | { type: 'SET_QUARANTINED_CHECKED'; payload: Record } + | { type: 'TOGGLE_QUARANTINED_CHECKED'; payload: { id: string; checked: boolean } } + | { type: 'SELECT_ALL_QUARANTINED'; payload: ScanResult[] } + | { type: 'DESELECT_ALL_QUARANTINED' } + | { type: 'SET_SCAN_DECISIONS'; payload: Record } + | { type: 'SET_FILES_CHECKED'; payload: Set } + + // Dialog actions + | { type: 'OPEN_CONFIRM_DIALOG'; payload: ConfirmActionType } + | { type: 'CLOSE_CONFIRM_DIALOG' } + | { type: 'OPEN_FILE_DIALOG'; payload: FileActionType } + | { type: 'CLOSE_FILE_DIALOG' } + + // Scan data actions (from /scans API) + | { type: 'SET_SCANS'; payload: { scans: ScanResult[]; totalScans: number } } + | { type: 'SET_LOADING_SCANS'; payload: boolean } + | { type: 'SET_SCAN_COUNTS'; payload: ScanCounts | null } + | { type: 'TRIGGER_REFRESH' } + | { type: 'SET_LAST_REFRESHED'; payload: Date } + | { type: 'SET_AUTO_REFRESH'; payload: boolean } + + // File data actions (from /files API) + | { type: 'SET_FILES'; payload: { files: FileDecision[]; totalFiles: number } } + | { type: 'SET_LOADING_FILES'; payload: boolean } + | { type: 'SET_FILE_COUNTS'; payload: FileDecisionCounts | null } + + // Confirm action execution + | { type: 'EXECUTE_CONFIRM_ACTION' }; + +// ============================================================================ +// Initial State +// ============================================================================ + +export const initialScanState: ScanState = { + // Tab state + selectedTab: 0, + + // Search state + publisherQuery: '', + namespaceQuery: '', + nameQuery: '', + + // Pagination state + currentPage: 0, + pageSize: 10, + + // Global filter state + dateRange: 'all', + enforcement: 'all', // Default for tab 0 (scans). Tabs 1 & 2 default to 'enforced' via SET_TAB + + // File-specific filter state + fileDateRange: 'all', + + // Tab-specific filter state + statusFilters: new Set(), + quarantineFilters: new Set(), + threatScannerFilters: new Set(), + validationTypeFilters: new Set(), + + // Available filter options + filterOptionsLoaded: false, + availableValidationTypes: [], + availableThreatScanners: [], + + // Menu anchor state + filterMenuAnchor: null, + quarantineFilterMenuAnchor: null, + autoRejectedFilterMenuAnchor: null, + + // Selection state + quarantinedChecked: {}, + scanDecisions: {}, + filesChecked: new Set(), + + // Dialog state + confirmDialogOpen: false, + confirmAction: null, + fileDialogOpen: false, + fileActionType: null, + + // Scan data state + scans: [], + totalScans: 0, + isLoadingScans: false, + scanCounts: null, + refreshTrigger: 0, + lastRefreshed: null, + autoRefresh: true, + + // File data state + files: [], + totalFiles: 0, + isLoadingFiles: false, + fileCounts: null, +}; diff --git a/webui/src/default/theme.tsx b/webui/src/default/theme.tsx index 9feac80d1..45c63b737 100644 --- a/webui/src/default/theme.tsx +++ b/webui/src/default/theme.tsx @@ -11,20 +11,66 @@ import { CSSProperties } from 'react'; import { createTheme, Theme } from '@mui/material'; +// Shared type definitions for palette extensions +type Color = CSSProperties['color']; + +interface StatusColors { + dark: Color; + light: Color; +} + +interface NeutralColors { + light: Color; + dark: Color; +} + +interface SelectedColors { + border: Color; + background: Color; + backgroundHover: Color; + hover: Color; +} + +interface ScanBackgroundColors { + default: Color; + light: Color; + dark: Color; +} + +interface GrayColors { + start: Color; + middle: Color; + end: Color; + gradient: string; +} + +interface UnenforcedColors { + stripe: string; +} + +// Shared shape for custom palette properties +interface CustomPaletteColors { + neutral: NeutralColors; + textHint: Color; + passed: StatusColors; + quarantined: StatusColors; + rejected: StatusColors; + errorStatus: StatusColors; + allowed: Color; + blocked: Color; + review: Color; + selected: SelectedColors; + scanBackground: ScanBackgroundColors; + gray: GrayColors; + unenforced: UnenforcedColors; +} + declare module '@mui/material/styles/createPalette' { - interface Palette { - neutral: { - light: CSSProperties['color'], - dark: CSSProperties['color'] - }, - textHint: CSSProperties['color'] - } - interface PaletteOptions { - neutral: { - light: CSSProperties['color'], - dark: CSSProperties['color'] - }, - textHint: CSSProperties['color'] + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface Palette extends CustomPaletteColors {} + interface PaletteOptions extends Partial { + neutral: NeutralColors; + textHint: Color; } } export default function createDefaultTheme(themeType: 'light' | 'dark'): Theme { @@ -43,6 +89,45 @@ export default function createDefaultTheme(themeType: 'light' | 'dark'): Theme { dark: themeType === 'dark' ? '#151515' : '#fff', }, textHint: 'rgba(0, 0, 0, 0.38)', + passed: { + dark: '#2e5c32', + light: '#a5d6a7', + }, + quarantined: { + dark: '#8e5518', + light: '#ffcc80', + }, + rejected: { + dark: '#7d2e2e', + light: '#ef9a9a', + }, + errorStatus: { + dark: '#5a5a5a', + light: '#b0b0b0', + }, + allowed: '#4caf50', + blocked: '#f44336', + review: '#ffc107', + selected: { + border: '#c160ef', + background: '#3d1b4d', + backgroundHover: '#4d2360', + hover: 'rgba(255, 255, 255, 0.1)', + }, + scanBackground: { + default: '#1e1e1e', + light: '#2d2d2d', + dark: '#0a0a0a', + }, + gray: { + start: '#888888', + middle: '#cccccc', + end: '#888888', + gradient: 'linear-gradient(90deg, #888888 0%, #cccccc 50%, #888888 100%)', + }, + unenforced: { + stripe: 'repeating-linear-gradient(-45deg, transparent, transparent 4px, rgba(255, 255, 255, 0.1) 4px, rgba(255, 255, 255, 0.1) 8px)', + }, mode: themeType }, breakpoints: { diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index e5ea6fb52..b9e20d46f 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -12,7 +12,9 @@ import { Extension, UserData, ExtensionCategory, ExtensionReviewList, PersonalAccessToken, SearchResult, NewReview, SuccessResult, ErrorResult, CsrfTokenJson, isError, Namespace, NamespaceDetails, MembershipRole, SortBy, SortOrder, UrlString, NamespaceMembershipList, PublisherInfo, SearchEntry, RegistryVersion, - LoginProviders + LoginProviders, ScanResultJson, ScanCounts, ScanResultsResponse, ScanFilterOptions, + FilesResponse, FileDecisionCountsJson, ScanDecisionRequest, ScanDecisionResponse, + FileDecisionRequest, FileDecisionResponse, FileDecisionDeleteRequest, FileDecisionDeleteResponse } from './extension-registry-types'; import { createAbsoluteURL, addQuery } from './utils'; import { sendRequest, ErrorResponse } from './server-request'; @@ -470,6 +472,17 @@ export interface AdminService { getPublisherInfo(abortController: AbortController, provider: string, login: string): Promise> revokePublisherContributions(abortController: AbortController, provider: string, login: string): Promise> revokeAccessTokens(abortController: AbortController, provider: string, login: string): Promise> + getAllScans(abortController: AbortController, params?: { size?: number; offset?: number; status?: string | string[]; publisher?: string; namespace?: string; name?: string; validationType?: string[]; threatScannerName?: string[]; dateStartedFrom?: string; dateStartedTo?: string; enforcement?: 'enforced' | 'notEnforced' | 'all' }): Promise> + getScan(abortController: AbortController, scanId: string): Promise> + getScanCounts(abortController: AbortController, params?: { dateStartedFrom?: string; dateStartedTo?: string; enforcement?: 'enforced' | 'notEnforced' | 'all'; threatScannerName?: string[]; validationType?: string[] }): Promise> + getScanFilterOptions(abortController: AbortController): Promise> + // Files API + getFiles(abortController: AbortController, params?: { size?: number; offset?: number; decision?: string; publisher?: string; namespace?: string; name?: string; dateDecidedFrom?: string; dateDecidedTo?: string; sortBy?: string; sortOrder?: 'asc' | 'desc' }): Promise>; + getFileCounts(abortController: AbortController, params?: { dateDecidedFrom?: string; dateDecidedTo?: string }): Promise> + // Decision APIs + makeScanDecision(abortController: AbortController, request: ScanDecisionRequest): Promise> + makeFileDecision(abortController: AbortController, request: FileDecisionRequest): Promise> + deleteFileDecisions(abortController: AbortController, request: FileDecisionDeleteRequest): Promise> } export interface AdminServiceConstructor { @@ -593,6 +606,168 @@ export class AdminServiceImpl implements AdminService { headers }); } + + getAllScans(abortController: AbortController, params?: { size?: number; offset?: number; status?: string | string[]; publisher?: string; namespace?: string; name?: string; validationType?: string[]; threatScannerName?: string[]; dateStartedFrom?: string; dateStartedTo?: string; enforcement?: 'enforced' | 'notEnforced' | 'all'; adminDecision?: string[] }): Promise> { + const url = new URL(createAbsoluteURL([this.registry.serverUrl, 'admin', 'api', 'scans'])); + if (params) { + if (params.size !== undefined) url.searchParams.set('size', params.size.toString()); + if (params.offset !== undefined) url.searchParams.set('offset', params.offset.toString()); + if (params.status) { + const statusValue = Array.isArray(params.status) ? params.status.join(',') : params.status; + url.searchParams.set('status', statusValue); + } + if (params.publisher) url.searchParams.set('publisher', params.publisher); + if (params.namespace) url.searchParams.set('namespace', params.namespace); + if (params.name) url.searchParams.set('name', params.name); + if (params.validationType && params.validationType.length > 0) { + url.searchParams.set('validationType', params.validationType.join(',')); + } + if (params.threatScannerName && params.threatScannerName.length > 0) { + url.searchParams.set('threatScannerName', params.threatScannerName.join(',')); + } + if (params.dateStartedFrom) url.searchParams.set('dateStartedFrom', params.dateStartedFrom); + if (params.dateStartedTo) url.searchParams.set('dateStartedTo', params.dateStartedTo); + if (params.enforcement) url.searchParams.set('enforcement', params.enforcement); + if (params.adminDecision && params.adminDecision.length > 0) { + url.searchParams.set('adminDecision', params.adminDecision.join(',')); + } + } + return sendRequest({ + abortController, + credentials: true, + endpoint: url.toString() + }); + } + + getScan(abortController: AbortController, scanId: string): Promise> { + return sendRequest({ + abortController, + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'api', 'scans', scanId]) + }); + } + + getScanCounts(abortController: AbortController, params?: { dateStartedFrom?: string; dateStartedTo?: string; enforcement?: 'enforced' | 'notEnforced' | 'all'; threatScannerName?: string[]; validationType?: string[] }): Promise> { + const url = new URL(createAbsoluteURL([this.registry.serverUrl, 'admin', 'api', 'scans', 'counts'])); + if (params) { + if (params.dateStartedFrom) url.searchParams.set('dateStartedFrom', params.dateStartedFrom); + if (params.dateStartedTo) url.searchParams.set('dateStartedTo', params.dateStartedTo); + if (params.enforcement) url.searchParams.set('enforcement', params.enforcement); + if (params.threatScannerName && params.threatScannerName.length > 0) { + url.searchParams.set('threatScannerName', params.threatScannerName.join(',')); + } + if (params.validationType && params.validationType.length > 0) { + url.searchParams.set('validationType', params.validationType.join(',')); + } + } + return sendRequest({ + abortController, + credentials: true, + endpoint: url.toString() + }); + } + + getScanFilterOptions(abortController: AbortController): Promise> { + return sendRequest({ + abortController, + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'api', 'scans', 'filterOptions']) + }); + } + + getFiles(abortController: AbortController, params?: { size?: number; offset?: number; decision?: string; publisher?: string; namespace?: string; name?: string; dateDecidedFrom?: string; dateDecidedTo?: string; sortBy?: string; sortOrder?: 'asc' | 'desc' }): Promise> { + const url = new URL(createAbsoluteURL([this.registry.serverUrl, 'admin', 'api', 'files'])); + if (params) { + if (params.size !== undefined) url.searchParams.set('size', String(params.size)); + if (params.offset !== undefined) url.searchParams.set('offset', String(params.offset)); + if (params.decision) url.searchParams.set('decision', params.decision); + if (params.publisher) url.searchParams.set('publisher', params.publisher); + if (params.namespace) url.searchParams.set('namespace', params.namespace); + if (params.name) url.searchParams.set('name', params.name); + if (params.dateDecidedFrom) url.searchParams.set('dateDecidedFrom', params.dateDecidedFrom); + if (params.dateDecidedTo) url.searchParams.set('dateDecidedTo', params.dateDecidedTo); + if (params.sortBy) url.searchParams.set('sortBy', params.sortBy); + if (params.sortOrder) url.searchParams.set('sortOrder', params.sortOrder); + } + return sendRequest({ + abortController, + credentials: true, + endpoint: url.toString() + }); + } + + getFileCounts(abortController: AbortController, params?: { dateDecidedFrom?: string; dateDecidedTo?: string }): Promise> { + const url = new URL(createAbsoluteURL([this.registry.serverUrl, 'admin', 'api', 'files', 'counts'])); + if (params) { + if (params.dateDecidedFrom) url.searchParams.set('dateDecidedFrom', params.dateDecidedFrom); + if (params.dateDecidedTo) url.searchParams.set('dateDecidedTo', params.dateDecidedTo); + } + return sendRequest({ + abortController, + credentials: true, + endpoint: url.toString() + }); + } + + async makeScanDecision(abortController: AbortController, request: ScanDecisionRequest): Promise> { + const csrfResponse = await this.registry.getCsrfToken(abortController); + const headers: Record = { + 'Content-Type': 'application/json;charset=UTF-8' + }; + if (!isError(csrfResponse)) { + const csrfToken = csrfResponse as CsrfTokenJson; + headers[csrfToken.header] = csrfToken.value; + } + + return sendRequest({ + abortController, + method: 'POST', + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'api', 'scans', 'decisions']), + headers, + payload: request + }); + } + + async makeFileDecision(abortController: AbortController, request: FileDecisionRequest): Promise> { + const csrfResponse = await this.registry.getCsrfToken(abortController); + const headers: Record = { + 'Content-Type': 'application/json;charset=UTF-8' + }; + if (!isError(csrfResponse)) { + const csrfToken = csrfResponse as CsrfTokenJson; + headers[csrfToken.header] = csrfToken.value; + } + + return sendRequest({ + abortController, + method: 'POST', + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'api', 'files', 'decisions']), + headers, + payload: request + }); + } + + async deleteFileDecisions(abortController: AbortController, request: FileDecisionDeleteRequest): Promise> { + const csrfResponse = await this.registry.getCsrfToken(abortController); + const headers: Record = { + 'Content-Type': 'application/json;charset=UTF-8' + }; + if (!isError(csrfResponse)) { + const csrfToken = csrfResponse as CsrfTokenJson; + headers[csrfToken.header] = csrfToken.value; + } + + return sendRequest({ + abortController, + method: 'DELETE', + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'api', 'files', 'decisions']), + headers, + payload: request + }); + } } export interface ExtensionFilter { diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index 6d50efb48..e7bb0c14a 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -258,3 +258,158 @@ export interface LoginProviders { export type MembershipRole = 'contributor' | 'owner'; export type SortBy = 'relevance' | 'timestamp' | 'rating' | 'downloadCount'; export type SortOrder = 'asc' | 'desc'; + +// Scan and file decision types (used by admin scan UI) +export interface ScanResultJson { + id: string; + namespace: string; + extensionName: string; + version: string; + displayName: string; + publisher: string; + extensionIcon?: string; + downloadUrl?: string; + publisherUrl?: string; + status: string; + dateScanStarted: string; + dateScanEnded?: string; + errorMessage?: string; + dateQuarantined?: string; + dateRejected?: string; + threats?: Array<{ + id: string; + fileName: string; + fileHash: string; + type: string; + severity?: string; + reason: string; + fileExtension: string; + dateDetected: string; + ruleName: string; + enforcedFlag?: boolean; + }>; + validationFailures?: Array<{ + id: string; + type: string; + ruleName: string; + reason: string; + dateDetected: string; + enforcedFlag: boolean; + }>; + adminDecision?: { + decision: string; + decidedBy: string; + dateDecided: string; + }; +} + +export interface ScanCounts { + STARTED: number; + VALIDATING: number; + SCANNING: number; + PASSED: number; + QUARANTINED: number; + AUTO_REJECTED: number; + ERROR: number; + ALLOWED: number; + BLOCKED: number; + NEEDS_REVIEW: number; +} + +export interface ScanResultsResponse { + success?: string; + warning?: string; + error?: string; + offset: number; + totalSize: number; + scans: ScanResultJson[]; +} + +export interface ScanFilterOptions { + validationTypes: string[]; + threatScannerNames: string[]; +} + +export interface FileDecisionJson { + id: string; + fileName: string; + fileHash: string; + fileType: string; + decision: string; + decidedBy: string; + dateDecided: string; + displayName: string; + namespace: string; + extensionName: string; + publisher: string; + version: string; + scanId?: string; +} + +export interface FilesResponse { + success?: string; + warning?: string; + error?: string; + offset: number; + totalSize: number; + files: FileDecisionJson[]; +} + +export interface FileDecisionCountsJson { + allowed: number; + blocked: number; + total: number; +} + +export interface ScanDecisionRequest { + scanIds: string[]; + decision: string; +} + +export interface ScanDecisionResult { + scanId: string; + success: boolean; + error?: string; +} + +export interface ScanDecisionResponse { + processed: number; + successful: number; + failed: number; + results: ScanDecisionResult[]; +} + +export interface FileDecisionRequest { + fileHashes: string[]; + decision: string; +} + +export interface FileDecisionResult { + fileHash: string; + success: boolean; + error?: string; +} + +export interface FileDecisionResponse { + processed: number; + successful: number; + failed: number; + results: FileDecisionResult[]; +} + +export interface FileDecisionDeleteRequest { + fileIds: string[]; +} + +export interface FileDecisionDeleteResult { + fileId: string; + success: boolean; + error?: string; +} + +export interface FileDecisionDeleteResponse { + processed: number; + successful: number; + failed: number; + results: FileDecisionDeleteResult[]; +} diff --git a/webui/src/hooks/scan-admin/index.ts b/webui/src/hooks/scan-admin/index.ts new file mode 100644 index 000000000..4706ad93a --- /dev/null +++ b/webui/src/hooks/scan-admin/index.ts @@ -0,0 +1,48 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +// Core hooks +export { useTabNavigation, TAB_DEFINITIONS } from './use-tab-navigation'; +export type { UseTabNavigationReturn, TabIndex, TabName } from './use-tab-navigation'; + +export { useScanFilters } from './use-scan-filters'; +export type { UseScanFiltersReturn } from './use-scan-filters'; + +export { usePagination } from './use-pagination'; +export type { UsePaginationReturn } from './use-pagination'; + +export { useSearch } from './use-search'; +export type { UseSearchReturn } from './use-search'; + +export { useDialogs } from './use-dialogs'; +export type { UseDialogsReturn } from './use-dialogs'; + +// Tab-specific hooks +export { useScansTab } from './use-scans-tab'; +export type { UseScansTabReturn } from './use-scans-tab'; + +export { useQuarantinedTab } from './use-quarantined-tab'; +export type { UseQuarantinedTabReturn } from './use-quarantined-tab'; + +export { useAutoRejectedTab } from './use-auto-rejected-tab'; +export type { UseAutoRejectedTabReturn } from './use-auto-rejected-tab'; + +export { useFileListTab, useAllowListTab, useBlockListTab } from './use-file-list-tab'; +export type { UseFileListTabReturn, UseAllowListTabReturn, UseBlockListTabReturn } from './use-file-list-tab'; + +// URL sync hook +export { useUrlSync } from './use-url-sync'; +export type { UseUrlSyncReturn } from './use-url-sync'; + +// Component-specific hooks +export { useScanCardState } from './use-scan-card-state'; diff --git a/webui/src/hooks/scan-admin/use-auto-rejected-tab.ts b/webui/src/hooks/scan-admin/use-auto-rejected-tab.ts new file mode 100644 index 000000000..34f7ce6eb --- /dev/null +++ b/webui/src/hooks/scan-admin/use-auto-rejected-tab.ts @@ -0,0 +1,85 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { useMemo } from 'react'; +import { useScanContext } from '../../context/scan-admin'; +import { useScanFilters } from './use-scan-filters'; +import { usePagination } from './use-pagination'; +import { useSearch } from './use-search'; + +/** + * Hook for the Auto Rejected tab (tab index 2). + * Provides auto-rejected scan data and validation type filtering. + */ +export const useAutoRejectedTab = () => { + const { state, actions } = useScanContext(); + const { globalFilters, validationTypeFilters } = useScanFilters(); + const pagination = usePagination(); + const search = useSearch(); + + // Use scans directly from state - backend handles filtering based on tab and enforcement + const autoRejectedScans = state.scans; + + // Get validation type breakdown + const validationTypeBreakdown = useMemo(() => { + const breakdown: Record = {}; + autoRejectedScans.forEach(scan => { + scan.validationFailures?.forEach(failure => { + const type = failure.type || 'Unknown'; + breakdown[type] = (breakdown[type] || 0) + 1; + }); + }); + return breakdown; + }, [autoRejectedScans]); + + // Get total count from the counts API (unaffected by search filters) + const totalCount = (state.scanCounts?.STARTED ?? 0) + (state.scanCounts?.VALIDATING ?? 0) + + (state.scanCounts?.SCANNING ?? 0) + (state.scanCounts?.PASSED ?? 0) + + (state.scanCounts?.QUARANTINED ?? 0) + (state.scanCounts?.AUTO_REJECTED ?? 0) + + (state.scanCounts?.ERROR ?? 0); + + // Check if there are any validation types available to filter by + const hasValidationTypes = state.availableValidationTypes.length > 0; + + return useMemo(() => ({ + tabIndex: 2, + tabName: 'Auto Rejected', + scans: autoRejectedScans, + isLoading: state.isLoadingScans, + lastRefreshed: state.lastRefreshed, + autoRefresh: state.autoRefresh, + onAutoRefreshChange: actions.setAutoRefresh, + totalCount, + hasValidationTypes, + search, + globalFilters, + validationTypeFilters, + pagination, + validationTypeBreakdown, + }), [ + autoRejectedScans, + state.isLoadingScans, + state.lastRefreshed, + state.autoRefresh, + actions.setAutoRefresh, + totalCount, + hasValidationTypes, + search, + globalFilters, + validationTypeFilters, + pagination, + validationTypeBreakdown, + ]); +}; + +export type UseAutoRejectedTabReturn = ReturnType; diff --git a/webui/src/hooks/scan-admin/use-dialogs.ts b/webui/src/hooks/scan-admin/use-dialogs.ts new file mode 100644 index 000000000..f9cb810f2 --- /dev/null +++ b/webui/src/hooks/scan-admin/use-dialogs.ts @@ -0,0 +1,83 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { useMemo } from 'react'; +import { useScanContext } from '../../context/scan-admin'; +import { ConfirmActionType, FileActionType, ScanResult, FileDecision } from '../../context/scan-admin/scan-types'; + +/** + * Return type for the useDialogs hook + */ +export interface UseDialogsReturn { + /** Confirm dialog state and actions (for extension-level allow/block) */ + confirmDialog: { + isOpen: boolean; + action: ConfirmActionType; + selectedExtensions: ScanResult[]; + close: () => void; + execute: () => void; + }; + /** File dialog state and actions (for file-level allow/block/delete) */ + fileDialog: { + isOpen: boolean; + actionType: FileActionType; + selectedFiles: FileDecision[]; + close: () => void; + execute: () => void; + }; + openAllowDialog: () => void; + openBlockDialog: () => void; + openFileDialog: (action: FileActionType) => void; +} + +/** + * Hook for dialog state and actions. + * Provides a clean interface for dialog components to consume. + */ +export const useDialogs = (): UseDialogsReturn => { + const { state, actions, derived } = useScanContext(); + + return useMemo(() => ({ + confirmDialog: { + isOpen: state.confirmDialogOpen, + action: state.confirmAction, + selectedExtensions: derived.selectedExtensions, + close: actions.closeConfirmDialog, + execute: actions.executeConfirmAction, + }, + fileDialog: { + isOpen: state.fileDialogOpen, + actionType: state.fileActionType, + selectedFiles: derived.selectedFiles, + close: actions.closeFileDialog, + execute: actions.executeFileAction, + }, + openAllowDialog: actions.openAllowDialog, + openBlockDialog: actions.openBlockDialog, + openFileDialog: actions.openFileDialog, + }), [ + state.confirmDialogOpen, + state.confirmAction, + state.fileDialogOpen, + state.fileActionType, + derived.selectedExtensions, + derived.selectedFiles, + actions.closeConfirmDialog, + actions.executeConfirmAction, + actions.closeFileDialog, + actions.executeFileAction, + actions.openAllowDialog, + actions.openBlockDialog, + actions.openFileDialog, + ]); +}; diff --git a/webui/src/hooks/scan-admin/use-file-list-tab.ts b/webui/src/hooks/scan-admin/use-file-list-tab.ts new file mode 100644 index 000000000..6f1c1199d --- /dev/null +++ b/webui/src/hooks/scan-admin/use-file-list-tab.ts @@ -0,0 +1,149 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { useMemo, useCallback } from 'react'; +import { useScanContext, FileDecisionType } from '../../context/scan-admin'; +import { usePagination } from './use-pagination'; +import { useSearch } from './use-search'; + +interface UseFileListTabOptions { + tabIndex: 3 | 4; + decisionType: FileDecisionType; + tabName: string; +} + +/** + * Hook for the Allowed Files (tab 3) and Blocked Files (tab 4) tabs. + * Provides file list data, selection management, and file actions. + */ +export const useFileListTab = ({ tabIndex, decisionType, tabName }: UseFileListTabOptions) => { + const { state, actions, derived } = useScanContext(); + const pagination = usePagination(); + const search = useSearch(); + + // Files are already filtered server-side by decision parameter + const files = state.files; + + // Auto-refresh state (shared with all tabs) + const lastRefreshed = state.lastRefreshed; + const autoRefresh = state.autoRefresh; + const onAutoRefreshChange = actions.setAutoRefresh; + + // Selection state (files already filtered server-side by decision) + const selection = useMemo(() => ({ + checked: state.filesChecked, + selectedCount: state.filesChecked.size, + selectedFiles: derived.selectedFiles, + }), [state.filesChecked, derived.selectedFiles]); + + // Selection actions + const setFilesChecked = useCallback((fileIds: Set) => { + actions.setFilesChecked(fileIds); + }, [actions]); + + const selectAll = useCallback(() => { + const allIds = new Set(files.map(f => f.id)); + actions.setFilesChecked(allIds); + }, [files, actions]); + + const deselectAll = useCallback(() => { + actions.setFilesChecked(new Set()); + }, [actions]); + + const isAllSelected = useMemo(() => { + return files.length > 0 && files.every(file => state.filesChecked.has(file.id)); + }, [files, state.filesChecked]); + + const isSomeSelected = useMemo(() => { + return files.some(file => state.filesChecked.has(file.id)) && !isAllSelected; + }, [files, state.filesChecked, isAllSelected]); + + // File actions + const fileActions = useMemo(() => ({ + openAllowDialog: () => actions.openFileDialog('allow'), + openBlockDialog: () => actions.openFileDialog('block'), + openDeleteDialog: () => actions.openFileDialog('delete'), + canPerformAction: selection.selectedCount > 0, + }), [actions, selection.selectedCount]); + + // Get count from file counts + const fileCount = state.fileCounts + ? (decisionType === 'allowed' ? state.fileCounts.allowed : state.fileCounts.blocked) + : 0; + + return useMemo(() => ({ + tabIndex, + tabName, + decisionType, + files, + isLoading: state.isLoadingFiles, + fileCount, + lastRefreshed, + autoRefresh, + onAutoRefreshChange, + search, + pagination, + selection, + setFilesChecked, + selectAll, + deselectAll, + isAllSelected, + isSomeSelected, + fileActions, + }), [ + tabIndex, + tabName, + decisionType, + files, + state.isLoadingFiles, + fileCount, + lastRefreshed, + autoRefresh, + onAutoRefreshChange, + search, + pagination, + selection, + setFilesChecked, + selectAll, + deselectAll, + isAllSelected, + isSomeSelected, + fileActions, + ]); +}; + +/** + * Hook specifically for the Allow List tab (tab index 3). + */ +export const useAllowListTab = () => { + return useFileListTab({ + tabIndex: 3, + decisionType: 'allowed', + tabName: 'Allow List', + }); +}; + +/** + * Hook specifically for the Block List tab (tab index 4). + */ +export const useBlockListTab = () => { + return useFileListTab({ + tabIndex: 4, + decisionType: 'blocked', + tabName: 'Block List', + }); +}; + +export type UseFileListTabReturn = ReturnType; +export type UseAllowListTabReturn = ReturnType; +export type UseBlockListTabReturn = ReturnType; diff --git a/webui/src/hooks/scan-admin/use-pagination.ts b/webui/src/hooks/scan-admin/use-pagination.ts new file mode 100644 index 000000000..195222fa4 --- /dev/null +++ b/webui/src/hooks/scan-admin/use-pagination.ts @@ -0,0 +1,90 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { useMemo, useCallback } from 'react'; +import { useScanContext } from '../../context/scan-admin'; + +/** + * Hook for managing pagination state and actions. + * Provides page navigation, total pages calculation, and loading state. + */ +export const usePagination = () => { + const { state, actions, derived } = useScanContext(); + + const goToPage = useCallback((page: number) => { + if (page >= 0 && page < derived.totalPages) { + actions.setPage(page); + } + }, [actions, derived.totalPages]); + + const goToNextPage = useCallback(() => { + if (state.currentPage < derived.totalPages - 1) { + actions.setPage(state.currentPage + 1); + } + }, [state.currentPage, derived.totalPages, actions]); + + const goToPreviousPage = useCallback(() => { + if (state.currentPage > 0) { + actions.setPage(state.currentPage - 1); + } + }, [state.currentPage, actions]); + + const goToFirstPage = useCallback(() => { + actions.setPage(0); + }, [actions]); + + const goToLastPage = useCallback(() => { + if (derived.totalPages > 0) { + actions.setPage(derived.totalPages - 1); + } + }, [derived.totalPages, actions]); + + // Determine if we're in a loading state based on selected tab + const isLoading = state.selectedTab >= 3 ? state.isLoadingFiles : state.isLoadingScans; + + // Get total items based on selected tab + const totalItems = state.selectedTab >= 3 ? state.totalFiles : state.totalScans; + + return useMemo(() => ({ + currentPage: state.currentPage, + pageSize: state.pageSize, + totalPages: derived.totalPages, + totalItems, + isLoading, + goToPage, + goToNextPage, + goToPreviousPage, + goToFirstPage, + goToLastPage, + hasNextPage: state.currentPage < derived.totalPages - 1, + hasPreviousPage: state.currentPage > 0, + isFirstPage: state.currentPage === 0, + isLastPage: state.currentPage >= derived.totalPages - 1, + // Range info (1-indexed for display) + startItem: totalItems > 0 ? state.currentPage * state.pageSize + 1 : 0, + endItem: Math.min((state.currentPage + 1) * state.pageSize, totalItems), + }), [ + state.currentPage, + state.pageSize, + derived.totalPages, + totalItems, + isLoading, + goToPage, + goToNextPage, + goToPreviousPage, + goToFirstPage, + goToLastPage, + ]); +}; + +export type UsePaginationReturn = ReturnType; diff --git a/webui/src/hooks/scan-admin/use-quarantined-tab.ts b/webui/src/hooks/scan-admin/use-quarantined-tab.ts new file mode 100644 index 000000000..f73893b77 --- /dev/null +++ b/webui/src/hooks/scan-admin/use-quarantined-tab.ts @@ -0,0 +1,138 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { useMemo, useCallback } from 'react'; +import { useScanContext } from '../../context/scan-admin'; +import { useScanFilters } from './use-scan-filters'; +import { usePagination } from './use-pagination'; +import { useSearch } from './use-search'; + +/** + * Hook for the Quarantined tab (tab index 1). + * Provides quarantined scan data, selection management, and bulk actions. + */ +export const useQuarantinedTab = () => { + const { state, actions, derived } = useScanContext(); + const { globalFilters, quarantineFilters } = useScanFilters(); + const pagination = usePagination(); + const search = useSearch(); + + // Scans are already filtered server-side by threatScannerName parameter + const quarantinedScans = state.scans; + + // Determine which scans are selectable (need review) + // A scan needs review if: no admin decision AND has at least one enforced threat + const selectableScans = useMemo(() => { + return quarantinedScans.filter(scan => { + const hasEnforcedThreat = scan.threats.some(t => t.enforcedFlag); + const needsReview = !scan.adminDecision?.decision && hasEnforcedThreat; + return needsReview; + }); + }, [quarantinedScans]); + + // Selection state + const selection = useMemo(() => { + const keys = Object.keys(state.quarantinedChecked); + const selectedCount = keys.filter(key => state.quarantinedChecked[key]).length; + return { + checked: state.quarantinedChecked, + selectedCount, + selectedExtensions: derived.selectedExtensions, + }; + }, [state.quarantinedChecked, derived.selectedExtensions]); + + // Selection actions + const toggleCheck = useCallback((id: string, checked: boolean) => { + actions.toggleQuarantinedCheck(id, checked); + }, [actions]); + + const selectAll = useCallback(() => { + // Only select scans that are selectable (need review) + actions.selectAllQuarantined(selectableScans); + }, [actions, selectableScans]); + + const deselectAll = useCallback(() => { + actions.deselectAllQuarantined(); + }, [actions]); + + const isAllSelected = useMemo(() => { + // Check if all selectable scans are selected + return selectableScans.length > 0 && + selectableScans.every(scan => state.quarantinedChecked[scan.id]); + }, [selectableScans, state.quarantinedChecked]); + + const isSomeSelected = useMemo(() => { + return selectableScans.some(scan => state.quarantinedChecked[scan.id]) && + !isAllSelected; + }, [selectableScans, state.quarantinedChecked, isAllSelected]); + + // Bulk actions + const bulkActions = useMemo(() => ({ + openAllowDialog: actions.openAllowDialog, + openBlockDialog: actions.openBlockDialog, + canPerformBulkAction: selection.selectedCount > 0, + }), [actions, selection.selectedCount]); + + // Get total count from the counts API (unaffected by search filters and admin decision checkbox filters) + const totalCount = (state.scanCounts?.STARTED ?? 0) + (state.scanCounts?.VALIDATING ?? 0) + + (state.scanCounts?.SCANNING ?? 0) + (state.scanCounts?.PASSED ?? 0) + + (state.scanCounts?.QUARANTINED ?? 0) + (state.scanCounts?.AUTO_REJECTED ?? 0) + + (state.scanCounts?.ERROR ?? 0); + + // Check if there are any threat scanners available to filter by + const hasThreatScanners = state.availableThreatScanners.length > 0; + + return useMemo(() => ({ + tabIndex: 1, + tabName: 'Quarantined', + scans: quarantinedScans, + isLoading: state.isLoadingScans, + lastRefreshed: state.lastRefreshed, + autoRefresh: state.autoRefresh, + onAutoRefreshChange: actions.setAutoRefresh, + totalCount, + hasThreatScanners, + search, + globalFilters, + quarantineFilters, + pagination, + selection, + toggleCheck, + selectAll, + deselectAll, + isAllSelected, + isSomeSelected, + bulkActions, + }), [ + quarantinedScans, + state.isLoadingScans, + state.lastRefreshed, + state.autoRefresh, + actions.setAutoRefresh, + totalCount, + hasThreatScanners, + search, + globalFilters, + quarantineFilters, + pagination, + selection, + toggleCheck, + selectAll, + deselectAll, + isAllSelected, + isSomeSelected, + bulkActions, + ]); +}; + +export type UseQuarantinedTabReturn = ReturnType; diff --git a/webui/src/hooks/scan-admin/use-scan-card-state.ts b/webui/src/hooks/scan-admin/use-scan-card-state.ts new file mode 100644 index 000000000..2ba29063f --- /dev/null +++ b/webui/src/hooks/scan-admin/use-scan-card-state.ts @@ -0,0 +1,73 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { useState, useEffect, useRef, useMemo } from 'react'; +import { ScanResult } from '../../context/scan-admin'; +import { formatDuration } from '../../components/scan-admin/common'; +import { + isRunning, + shouldShowExpandButton, + getDetailBadges, + DetailBadge, +} from '../../components/scan-admin/scan-card/utils'; + +interface UseScanCardStateReturn { + expanded: boolean; + handleExpandClick: () => void; + showExpandButton: boolean; + badges: DetailBadge[]; + liveDuration: string; + // Card ref for height tracking + cardRef: React.RefObject; +} + +/** + * Custom hook that encapsulates all ScanCard state logic. + */ +export const useScanCardState = (scan: ScanResult): UseScanCardStateReturn => { + const [expanded, setExpanded] = useState(false); + const [liveDuration, setLiveDuration] = useState(''); + + const cardRef = useRef(null); + + const badges = useMemo(() => getDetailBadges(scan), [scan]); + + const showExpandButton = shouldShowExpandButton(scan); + + const handleExpandClick = () => { + setExpanded(!expanded); + }; + + // Live duration for running scans + useEffect(() => { + if (isRunning(scan.status) && scan.dateScanStarted) { + const updateDuration = () => { + const duration = formatDuration(scan.dateScanStarted, new Date().toISOString()); + setLiveDuration(duration); + }; + updateDuration(); + const interval = setInterval(updateDuration, 1000); + return () => clearInterval(interval); + } + return undefined; + }, [scan.status, scan.dateScanStarted]); + + return { + expanded, + handleExpandClick, + showExpandButton, + badges, + liveDuration, + cardRef, + }; +}; diff --git a/webui/src/hooks/scan-admin/use-scan-filters.ts b/webui/src/hooks/scan-admin/use-scan-filters.ts new file mode 100644 index 000000000..8161f9634 --- /dev/null +++ b/webui/src/hooks/scan-admin/use-scan-filters.ts @@ -0,0 +1,83 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { useMemo } from 'react'; +import { useScanContext } from '../../context/scan-admin'; + +/** + * Hook for managing scan filter state and actions. + * Provides access to global filters (date range, enforcement) and + * tab-specific filters (status, quarantine, threat scanner, validation type). + */ +export const useScanFilters = () => { + const { state, actions } = useScanContext(); + + // Global filters + const globalFilters = useMemo(() => ({ + dateRange: state.dateRange, + enforcement: state.enforcement, + setDateRange: actions.setDateRange, + setEnforcement: actions.setEnforcement, + }), [state.dateRange, state.enforcement, actions]); + + // Status filters (for Scans tab) + const statusFilters = useMemo(() => ({ + filters: state.statusFilters, + toggle: actions.toggleStatusFilter, + menuAnchor: state.filterMenuAnchor, + openMenu: actions.openFilterMenu, + closeMenu: actions.closeFilterMenu, + }), [state.statusFilters, state.filterMenuAnchor, actions]); + + // Quarantine filters (for Quarantined tab) + const quarantineFilters = useMemo(() => ({ + filters: state.quarantineFilters, + toggle: actions.toggleQuarantineFilter, + threatScannerFilters: state.threatScannerFilters, + toggleThreatScanner: actions.toggleThreatScannerFilter, + availableThreatScanners: state.availableThreatScanners, + menuAnchor: state.quarantineFilterMenuAnchor, + openMenu: actions.openQuarantineFilterMenu, + closeMenu: actions.closeQuarantineFilterMenu, + }), [ + state.quarantineFilters, + state.threatScannerFilters, + state.availableThreatScanners, + state.quarantineFilterMenuAnchor, + actions, + ]); + + // Validation type filters (for Auto Rejected tab) + const validationTypeFilters = useMemo(() => ({ + filters: state.validationTypeFilters, + toggle: actions.toggleValidationTypeFilter, + availableValidationTypes: state.availableValidationTypes, + menuAnchor: state.autoRejectedFilterMenuAnchor, + openMenu: actions.openAutoRejectedFilterMenu, + closeMenu: actions.closeAutoRejectedFilterMenu, + }), [ + state.validationTypeFilters, + state.availableValidationTypes, + state.autoRejectedFilterMenuAnchor, + actions, + ]); + + return { + globalFilters, + statusFilters, + quarantineFilters, + validationTypeFilters, + }; +}; + +export type UseScanFiltersReturn = ReturnType; diff --git a/webui/src/hooks/scan-admin/use-scans-tab.ts b/webui/src/hooks/scan-admin/use-scans-tab.ts new file mode 100644 index 000000000..d9d38d73a --- /dev/null +++ b/webui/src/hooks/scan-admin/use-scans-tab.ts @@ -0,0 +1,92 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { useMemo } from 'react'; +import { useScanContext } from '../../context/scan-admin'; +import { useScanFilters } from './use-scan-filters'; +import { usePagination } from './use-pagination'; +import { useSearch } from './use-search'; + +/** + * Hook for the main Scans tab (tab index 0). + * Provides all data and actions needed for displaying scan overview. + */ +export const useScansTab = () => { + const { state, actions } = useScanContext(); + const { globalFilters, statusFilters } = useScanFilters(); + const pagination = usePagination(); + const search = useSearch(); + + // Get scan counts for display + const counts = useMemo(() => { + if (!state.scanCounts) { + return { + started: 0, + validating: 0, + scanning: 0, + passed: 0, + quarantined: 0, + autoRejected: 0, + error: 0, + allowed: 0, + blocked: 0, + needsReview: 0, + total: 0, + }; + } + return { + started: state.scanCounts.STARTED, + validating: state.scanCounts.VALIDATING, + scanning: state.scanCounts.SCANNING, + passed: state.scanCounts.PASSED, + quarantined: state.scanCounts.QUARANTINED, + autoRejected: state.scanCounts.AUTO_REJECTED, + error: state.scanCounts.ERROR, + allowed: state.scanCounts.ALLOWED, + blocked: state.scanCounts.BLOCKED, + needsReview: state.scanCounts.NEEDS_REVIEW, + // Total = sum of scan statuses only (ALLOWED/BLOCKED/NEEDS_REVIEW are admin decisions, not statuses) + total: state.scanCounts.STARTED + state.scanCounts.VALIDATING + state.scanCounts.SCANNING + + state.scanCounts.PASSED + state.scanCounts.QUARANTINED + state.scanCounts.AUTO_REJECTED + + state.scanCounts.ERROR, + }; + }, [state.scanCounts]); + + return useMemo(() => ({ + tabIndex: 0, + tabName: 'Scans', + scans: state.scans, + isLoading: state.isLoadingScans, + lastRefreshed: state.lastRefreshed, + autoRefresh: state.autoRefresh, + onAutoRefreshChange: actions.setAutoRefresh, + counts, + search, + globalFilters, + statusFilters, + pagination, + }), [ + state.scans, + state.isLoadingScans, + state.lastRefreshed, + state.autoRefresh, + actions.setAutoRefresh, + counts, + search, + globalFilters, + statusFilters, + pagination, + ]); +}; + +export type UseScansTabReturn = ReturnType; diff --git a/webui/src/hooks/scan-admin/use-search.ts b/webui/src/hooks/scan-admin/use-search.ts new file mode 100644 index 000000000..ccf68f577 --- /dev/null +++ b/webui/src/hooks/scan-admin/use-search.ts @@ -0,0 +1,54 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { useMemo, useCallback } from 'react'; +import { useScanContext } from '../../context/scan-admin'; + +/** + * Hook for managing search state and actions. + * Provides publisher, namespace, and name search functionality. + */ +export const useSearch = () => { + const { state, actions, dispatch } = useScanContext(); + + const clearSearch = useCallback(() => { + dispatch({ type: 'CLEAR_SEARCH' }); + }, [dispatch]); + + const hasActiveSearch = useMemo(() => { + return !!(state.publisherQuery || state.namespaceQuery || state.nameQuery); + }, [state.publisherQuery, state.namespaceQuery, state.nameQuery]); + + return useMemo(() => ({ + publisherQuery: state.publisherQuery, + namespaceQuery: state.namespaceQuery, + nameQuery: state.nameQuery, + setPublisherQuery: actions.setPublisherQuery, + setNamespaceQuery: actions.setNamespaceQuery, + setNameQuery: actions.setNameQuery, + handlePublisherChange: actions.handlePublisherChange, + handleNamespaceChange: actions.handleNamespaceChange, + handleNameChange: actions.handleNameChange, + clearSearch, + hasActiveSearch, + }), [ + state.publisherQuery, + state.namespaceQuery, + state.nameQuery, + actions, + clearSearch, + hasActiveSearch, + ]); +}; + +export type UseSearchReturn = ReturnType; diff --git a/webui/src/hooks/scan-admin/use-tab-navigation.ts b/webui/src/hooks/scan-admin/use-tab-navigation.ts new file mode 100644 index 000000000..9bd6c8afe --- /dev/null +++ b/webui/src/hooks/scan-admin/use-tab-navigation.ts @@ -0,0 +1,82 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { useMemo, useCallback } from 'react'; +import { useScanContext } from '../../context/scan-admin'; + +/** + * Tab definitions for the Scans Admin page. + */ +export const TAB_DEFINITIONS = [ + { index: 0, name: 'Scans', path: 'scans' }, + { index: 1, name: 'Quarantined', path: 'quarantined' }, + { index: 2, name: 'Auto Rejected', path: 'auto-rejected' }, + { index: 3, name: 'Allowed Files', path: 'allowed-files' }, + { index: 4, name: 'Blocked Files', path: 'blocked-files' }, +] as const; + +export type TabIndex = 0 | 1 | 2 | 3 | 4; +export type TabName = typeof TAB_DEFINITIONS[number]['name']; + +/** + * Hook for managing tab navigation state and actions. + */ +export const useTabNavigation = () => { + const { state, actions } = useScanContext(); + + const setTab = useCallback((tab: number) => { + if (tab >= 0 && tab <= 4) { + actions.setTab(tab); + } + }, [actions]); + + const currentTab = useMemo(() => { + return TAB_DEFINITIONS[state.selectedTab] || TAB_DEFINITIONS[0]; + }, [state.selectedTab]); + + const isScansTab = state.selectedTab === 0; + const isQuarantinedTab = state.selectedTab === 1; + const isAutoRejectedTab = state.selectedTab === 2; + const isAllowListTab = state.selectedTab === 3; + const isBlockListTab = state.selectedTab === 4; + + const isScanDataTab = state.selectedTab <= 2; + const isFileDataTab = state.selectedTab >= 3; + + return useMemo(() => ({ + selectedTab: state.selectedTab, + currentTab, + tabs: TAB_DEFINITIONS, + setTab, + isScansTab, + isQuarantinedTab, + isAutoRejectedTab, + isAllowListTab, + isBlockListTab, + isScanDataTab, + isFileDataTab, + }), [ + state.selectedTab, + currentTab, + setTab, + isScansTab, + isQuarantinedTab, + isAutoRejectedTab, + isAllowListTab, + isBlockListTab, + isScanDataTab, + isFileDataTab, + ]); +}; + +export type UseTabNavigationReturn = ReturnType; diff --git a/webui/src/hooks/scan-admin/use-url-sync.ts b/webui/src/hooks/scan-admin/use-url-sync.ts new file mode 100644 index 000000000..c2dcc0444 --- /dev/null +++ b/webui/src/hooks/scan-admin/use-url-sync.ts @@ -0,0 +1,293 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { useEffect, useRef, useCallback } from 'react'; +import { useScanContext } from '../../context/scan-admin'; +import { DateRangeType, EnforcementType } from '../../context/scan-admin/scan-types'; + +/** + * URL parameter keys + */ +const URL_PARAMS = { + TAB: 'tab', + PAGE: 'page', + PUBLISHER: 'publisher', + NAMESPACE: 'namespace', + NAME: 'name', + DATE_RANGE: 'dateRange', + FILE_DATE_RANGE: 'fileDateRange', + ENFORCEMENT: 'enforcement', + // Checkbox filters + STATUS_FILTERS: 'status', + QUARANTINE_FILTERS: 'quarantine', + THREAT_SCANNER_FILTERS: 'threatScanner', + VALIDATION_TYPE_FILTERS: 'validationType', +} as const; + +/** + * Tab name to index mapping + */ +const TAB_NAME_TO_INDEX: Record = { + 'scans': 0, + 'quarantined': 1, + 'auto-rejected': 2, + 'allowed-files': 3, + 'blocked-files': 4, +}; + +const TAB_INDEX_TO_NAME: Record = { + 0: 'scans', + 1: 'quarantined', + 2: 'auto-rejected', + 3: 'allowed-files', + 4: 'blocked-files', +}; + +/** + * Valid date range values + */ +const VALID_DATE_RANGES: DateRangeType[] = ['today', 'last7days', 'last30days', 'last90days', 'all']; + +/** + * Valid enforcement values + */ +const VALID_ENFORCEMENTS: EnforcementType[] = ['enforced', 'notEnforced', 'all']; + +/** + * Hook to sync scan admin state with URL parameters. + * This enables bookmarkable URLs and browser back/forward navigation. + */ +export const useUrlSync = () => { + const { state, dispatch } = useScanContext(); + const isInitialized = useRef(false); + const isUpdatingFromUrl = useRef(false); + + /** + * Parse URL parameters and apply to state on initial load + */ + const initializeFromUrl = useCallback(() => { + const params = new URLSearchParams(window.location.search); + + // Tab + const tabParam = params.get(URL_PARAMS.TAB); + if (tabParam && TAB_NAME_TO_INDEX[tabParam] !== undefined) { + dispatch({ type: 'SET_TAB', payload: TAB_NAME_TO_INDEX[tabParam] }); + } + + // Page (URL is 1-indexed, state is 0-indexed) + const pageParam = params.get(URL_PARAMS.PAGE); + if (pageParam) { + const page = parseInt(pageParam, 10); + if (!isNaN(page) && page >= 1) { + dispatch({ type: 'SET_PAGE', payload: page - 1 }); + } + } + + // Search queries + const publisherParam = params.get(URL_PARAMS.PUBLISHER); + if (publisherParam) { + dispatch({ type: 'SET_PUBLISHER_QUERY', payload: publisherParam }); + } + + const namespaceParam = params.get(URL_PARAMS.NAMESPACE); + if (namespaceParam) { + dispatch({ type: 'SET_NAMESPACE_QUERY', payload: namespaceParam }); + } + + const nameParam = params.get(URL_PARAMS.NAME); + if (nameParam) { + dispatch({ type: 'SET_NAME_QUERY', payload: nameParam }); + } + + // Date range + const dateRangeParam = params.get(URL_PARAMS.DATE_RANGE); + if (dateRangeParam && VALID_DATE_RANGES.indexOf(dateRangeParam as DateRangeType) !== -1) { + dispatch({ type: 'SET_DATE_RANGE', payload: dateRangeParam as DateRangeType }); + } + + // File date range + const fileDateRangeParam = params.get(URL_PARAMS.FILE_DATE_RANGE); + if (fileDateRangeParam && VALID_DATE_RANGES.indexOf(fileDateRangeParam as DateRangeType) !== -1) { + dispatch({ type: 'SET_FILE_DATE_RANGE', payload: fileDateRangeParam as DateRangeType }); + } + + // Enforcement + const enforcementParam = params.get(URL_PARAMS.ENFORCEMENT); + if (enforcementParam && VALID_ENFORCEMENTS.indexOf(enforcementParam as EnforcementType) !== -1) { + dispatch({ type: 'SET_ENFORCEMENT', payload: enforcementParam as EnforcementType }); + } + + // Status filters (comma-separated) + const statusFiltersParam = params.get(URL_PARAMS.STATUS_FILTERS); + if (statusFiltersParam) { + const filters = new Set(statusFiltersParam.split(',').filter(Boolean)); + if (filters.size > 0) { + dispatch({ type: 'SET_STATUS_FILTERS', payload: filters }); + } + } + + // Quarantine filters (comma-separated) + const quarantineFiltersParam = params.get(URL_PARAMS.QUARANTINE_FILTERS); + if (quarantineFiltersParam) { + const filters = new Set(quarantineFiltersParam.split(',').filter(Boolean)); + if (filters.size > 0) { + dispatch({ type: 'SET_QUARANTINE_FILTERS', payload: filters }); + } + } + + // Threat scanner filters (comma-separated) + const threatScannerFiltersParam = params.get(URL_PARAMS.THREAT_SCANNER_FILTERS); + if (threatScannerFiltersParam) { + const filters = new Set(threatScannerFiltersParam.split(',').filter(Boolean)); + if (filters.size > 0) { + dispatch({ type: 'SET_THREAT_SCANNER_FILTERS', payload: filters }); + } + } + + // Validation type filters (comma-separated) + const validationTypeFiltersParam = params.get(URL_PARAMS.VALIDATION_TYPE_FILTERS); + if (validationTypeFiltersParam) { + const filters = new Set(validationTypeFiltersParam.split(',').filter(Boolean)); + if (filters.size > 0) { + dispatch({ type: 'SET_VALIDATION_TYPE_FILTERS', payload: filters }); + } + } + }, [dispatch]); + + /** + * Update URL parameters from state + */ + const updateUrlFromState = useCallback(() => { + if (isUpdatingFromUrl.current) { + return; + } + + const params = new URLSearchParams(); + + // Tab (only add if not default) + if (state.selectedTab !== 0) { + params.set(URL_PARAMS.TAB, TAB_INDEX_TO_NAME[state.selectedTab] || 'scans'); + } + + // Page (only add if not first page, URL is 1-indexed) + if (state.currentPage > 0) { + params.set(URL_PARAMS.PAGE, String(state.currentPage + 1)); + } + + // Search queries (only add if not empty) + if (state.publisherQuery) { + params.set(URL_PARAMS.PUBLISHER, state.publisherQuery); + } + if (state.namespaceQuery) { + params.set(URL_PARAMS.NAMESPACE, state.namespaceQuery); + } + if (state.nameQuery) { + params.set(URL_PARAMS.NAME, state.nameQuery); + } + + // Date range (only add if not default) + if (state.dateRange !== 'all') { + params.set(URL_PARAMS.DATE_RANGE, state.dateRange); + } + + // File date range (only add if not default) + if (state.fileDateRange !== 'all') { + params.set(URL_PARAMS.FILE_DATE_RANGE, state.fileDateRange); + } + + // Enforcement (only add if not tab-specific default) + // Tabs 1 (Quarantined) and 2 (Auto Rejected) default to 'enforced', others default to 'all' + const defaultEnforcement = (state.selectedTab === 1 || state.selectedTab === 2) ? 'enforced' : 'all'; + if (state.enforcement !== defaultEnforcement) { + params.set(URL_PARAMS.ENFORCEMENT, state.enforcement); + } + + // Status filters (only add if any are selected) + if (state.statusFilters.size > 0) { + params.set(URL_PARAMS.STATUS_FILTERS, Array.from(state.statusFilters).join(',')); + } + + // Quarantine filters (only add if any are selected - default is empty) + if (state.quarantineFilters.size > 0) { + params.set(URL_PARAMS.QUARANTINE_FILTERS, Array.from(state.quarantineFilters).join(',')); + } + + // Threat scanner filters (only add if any are selected) + if (state.threatScannerFilters.size > 0) { + params.set(URL_PARAMS.THREAT_SCANNER_FILTERS, Array.from(state.threatScannerFilters).join(',')); + } + + // Validation type filters (only add if any are selected) + if (state.validationTypeFilters.size > 0) { + params.set(URL_PARAMS.VALIDATION_TYPE_FILTERS, Array.from(state.validationTypeFilters).join(',')); + } + + // Build new URL + const newSearch = params.toString(); + const newUrl = newSearch + ? `${window.location.pathname}?${newSearch}` + : window.location.pathname; + + // Update URL without triggering a page reload + if (window.location.search !== (newSearch ? `?${newSearch}` : '')) { + window.history.replaceState(null, '', newUrl); + } + }, [state.selectedTab, state.currentPage, state.publisherQuery, state.namespaceQuery, state.nameQuery, state.dateRange, state.fileDateRange, state.enforcement, state.statusFilters, state.quarantineFilters, state.threatScannerFilters, state.validationTypeFilters]); + + /** + * Handle browser back/forward navigation + */ + const handlePopState = useCallback(() => { + isUpdatingFromUrl.current = true; + initializeFromUrl(); + // Reset the flag after a short delay to allow state to settle + setTimeout(() => { + isUpdatingFromUrl.current = false; + }, 100); + }, [initializeFromUrl]); + + // Initialize from URL on mount + useEffect(() => { + if (!isInitialized.current) { + isInitialized.current = true; + isUpdatingFromUrl.current = true; + initializeFromUrl(); + // Reset the flag after initialization + setTimeout(() => { + isUpdatingFromUrl.current = false; + }, 100); + } + }, [initializeFromUrl]); + + // Update URL when state changes + useEffect(() => { + if (isInitialized.current) { + updateUrlFromState(); + } + }, [updateUrlFromState]); + + // Listen for browser back/forward + useEffect(() => { + window.addEventListener('popstate', handlePopState); + return () => { + window.removeEventListener('popstate', handlePopState); + }; + }, [handlePopState]); + + return { + initializeFromUrl, + updateUrlFromState, + }; +}; + +export type UseUrlSyncReturn = ReturnType; diff --git a/webui/src/pages/admin-dashboard/admin-dashboard.tsx b/webui/src/pages/admin-dashboard/admin-dashboard.tsx index 4df98e83f..93667d810 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard.tsx +++ b/webui/src/pages/admin-dashboard/admin-dashboard.tsx @@ -23,6 +23,8 @@ import HighlightOffIcon from '@mui/icons-material/HighlightOff'; import { Welcome } from './welcome'; import { PublisherAdmin } from './publisher-admin'; import PersonIcon from '@mui/icons-material/Person'; +import { ScanAdmin } from './scan-admin'; +import SecurityIcon from '@mui/icons-material/Security'; export namespace AdminDashboardRoutes { export const ROOT = 'admin-dashboard'; @@ -30,6 +32,7 @@ export namespace AdminDashboardRoutes { export const NAMESPACE_ADMIN = createRoute([ROOT, 'namespaces']); export const EXTENSION_ADMIN = createRoute([ROOT, 'extensions']); export const PUBLISHER_ADMIN = createRoute([ROOT, 'publisher']); + export const SCANS_ADMIN = createRoute([ROOT, 'scans']); } const Message: FunctionComponent<{message: string}> = ({ message }) => { @@ -59,16 +62,37 @@ export const AdminDashboard: FunctionComponent = props => { } route={AdminDashboardRoutes.NAMESPACE_ADMIN} /> } route={AdminDashboardRoutes.EXTENSION_ADMIN} /> } route={AdminDashboardRoutes.PUBLISHER_ADMIN} /> + } route={AdminDashboardRoutes.SCANS_ADMIN} /> - + - + } /> } /> } /> + } /> } /> diff --git a/webui/src/pages/admin-dashboard/scan-admin.tsx b/webui/src/pages/admin-dashboard/scan-admin.tsx new file mode 100644 index 000000000..467cd43cf --- /dev/null +++ b/webui/src/pages/admin-dashboard/scan-admin.tsx @@ -0,0 +1,109 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import React, { FunctionComponent, useContext } from 'react'; +import { Box, Typography } from '@mui/material'; +import { ScanProvider } from '../../context/scan-admin'; +import { useTabNavigation, useUrlSync } from '../../hooks/scan-admin'; +import { + TabToolbar, + TabPanel, + ScansTabContent, + QuarantinedTabContent, + AutoRejectedTabContent, + AllowListTabContent, + BlockListTabContent, + QuarantineDialog, + FileDialog, +} from '../../components/scan-admin'; +import { MainContext } from '../../context'; + +/** + * Inner component that consumes the ScanContext. + * Uses lean tab components that consume context via hooks. + */ +const ScanAdminContent: FunctionComponent = () => { + const { selectedTab, setTab } = useTabNavigation(); + + // Sync state with URL parameters for bookmarkable URLs and browser navigation + useUrlSync(); + + const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { + setTab(newValue); + }; + + return ( + + + Extension Scans + + + + + {/* Tab panels - each tab content component consumes context via hooks */} + + + + + + + + + + + + + + + + + + + + + + {/* Dialogs - consume context via hooks */} + + + + ); +}; + +/** + * Main ScanAdmin component that provides the context. + * This is the entry point for the scan administration feature. + */ +export const ScanAdmin: FunctionComponent = () => { + const { service, handleError } = useContext(MainContext); + + return ( + + + + ); +}; + +export default ScanAdmin; diff --git a/webui/src/pages/admin-dashboard/welcome.tsx b/webui/src/pages/admin-dashboard/welcome.tsx index 1ab0d42ed..0aad7ee7e 100644 --- a/webui/src/pages/admin-dashboard/welcome.tsx +++ b/webui/src/pages/admin-dashboard/welcome.tsx @@ -27,6 +27,7 @@ export const Welcome: FunctionComponent = props => { + From d7935fc641f59f9d4d33544ea6c2a913ef908fae Mon Sep 17 00:00:00 2001 From: Alejandro N Rivera Date: Mon, 12 Jan 2026 15:20:32 -0500 Subject: [PATCH 7/9] fix admin scans api requests (#1540) Co-authored-by: Alejandro Munoz --- .../openvsx/admin/FileDecisionAPI.java | 2 +- .../org/eclipse/openvsx/admin/ScanAPI.java | 12 +- webui/src/extension-registry-service.ts | 120 +++++++++++------- 3 files changed, 79 insertions(+), 55 deletions(-) diff --git a/server/src/main/java/org/eclipse/openvsx/admin/FileDecisionAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/FileDecisionAPI.java index e5bc29294..3b5887f0b 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/FileDecisionAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/FileDecisionAPI.java @@ -38,7 +38,7 @@ * Provides endpoints for managing file-level security decisions. */ @RestController -@RequestMapping("/admin/api") +@RequestMapping("/admin/scans") @ApiResponse( responseCode = "403", description = "Administration role is required", diff --git a/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java index b947e3bdc..d537d660a 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java @@ -53,7 +53,7 @@ * Used by the admin dashboard to monitor extension validation and scanning. */ @RestController -@RequestMapping("/admin/api") +@RequestMapping("/admin/scans") @ApiResponse( responseCode = "403", description = "Administration role is required", @@ -79,7 +79,7 @@ public ScanAPI( * Get aggregated scan counts by status and admin decisions counts. */ @GetMapping( - path = "/scans/counts", + path = "/counts", produces = MediaType.APPLICATION_JSON_VALUE ) @CrossOrigin @@ -224,7 +224,7 @@ private EnforcementFilter parseEnforcementFilter(String enforcement) { * Get all extension scans with filtering, sorting and pagination. */ @GetMapping( - path = "/scans", + path = "", produces = MediaType.APPLICATION_JSON_VALUE ) @CrossOrigin @@ -484,7 +484,7 @@ private Sort createSort(String sortBy, boolean ascending) { * Returns distinct values that can be used to filter the scan list. */ @GetMapping( - path = "/scans/filterOptions", + path = "/filterOptions", produces = MediaType.APPLICATION_JSON_VALUE ) @CrossOrigin @@ -518,7 +518,7 @@ public ResponseEntity getScanFilterOptions() { * Returns detailed information about a single scan. */ @GetMapping( - path = "/scans/{scanId}", + path = "/{scanId}", produces = MediaType.APPLICATION_JSON_VALUE ) @CrossOrigin @@ -564,7 +564,7 @@ public ResponseEntity getScan( * Pass a single scanId for individual decisions, or multiple scanIds for bulk operations. */ @PostMapping( - path = "/scans/decisions", + path = "/decisions", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE ) diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index b9e20d46f..4382915de 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -608,34 +608,40 @@ export class AdminServiceImpl implements AdminService { } getAllScans(abortController: AbortController, params?: { size?: number; offset?: number; status?: string | string[]; publisher?: string; namespace?: string; name?: string; validationType?: string[]; threatScannerName?: string[]; dateStartedFrom?: string; dateStartedTo?: string; enforcement?: 'enforced' | 'notEnforced' | 'all'; adminDecision?: string[] }): Promise> { - const url = new URL(createAbsoluteURL([this.registry.serverUrl, 'admin', 'api', 'scans'])); + const query: { key: string, value: string | number }[] = []; if (params) { - if (params.size !== undefined) url.searchParams.set('size', params.size.toString()); - if (params.offset !== undefined) url.searchParams.set('offset', params.offset.toString()); + if (params.size !== undefined) + query.push({ key: 'size', value: params.size }); + if (params.offset !== undefined) + query.push({ key: 'offset', value: params.offset }); if (params.status) { const statusValue = Array.isArray(params.status) ? params.status.join(',') : params.status; - url.searchParams.set('status', statusValue); - } - if (params.publisher) url.searchParams.set('publisher', params.publisher); - if (params.namespace) url.searchParams.set('namespace', params.namespace); - if (params.name) url.searchParams.set('name', params.name); - if (params.validationType && params.validationType.length > 0) { - url.searchParams.set('validationType', params.validationType.join(',')); - } - if (params.threatScannerName && params.threatScannerName.length > 0) { - url.searchParams.set('threatScannerName', params.threatScannerName.join(',')); - } - if (params.dateStartedFrom) url.searchParams.set('dateStartedFrom', params.dateStartedFrom); - if (params.dateStartedTo) url.searchParams.set('dateStartedTo', params.dateStartedTo); - if (params.enforcement) url.searchParams.set('enforcement', params.enforcement); - if (params.adminDecision && params.adminDecision.length > 0) { - url.searchParams.set('adminDecision', params.adminDecision.join(',')); + query.push({ key: 'status', value: statusValue }); } + if (params.publisher) + query.push({ key: 'publisher', value: params.publisher }); + if (params.namespace) + query.push({ key: 'namespace', value: params.namespace }); + if (params.name) + query.push({ key: 'name', value: params.name }); + if (params.validationType && params.validationType.length > 0) + query.push({ key: 'validationType', value: params.validationType.join(',') }); + if (params.threatScannerName && params.threatScannerName.length > 0) + query.push({ key: 'threatScannerName', value: params.threatScannerName.join(',') }); + if (params.dateStartedFrom) + query.push({ key: 'dateStartedFrom', value: params.dateStartedFrom }); + if (params.dateStartedTo) + query.push({ key: 'dateStartedTo', value: params.dateStartedTo }); + if (params.enforcement) + query.push({ key: 'enforcement', value: params.enforcement }); + if (params.adminDecision && params.adminDecision.length > 0) + query.push({ key: 'adminDecision', value: params.adminDecision.join(',') }); } + const endpoint = createAbsoluteURL([this.registry.serverUrl, 'admin', 'scans'], query); return sendRequest({ abortController, credentials: true, - endpoint: url.toString() + endpoint }); } @@ -643,27 +649,31 @@ export class AdminServiceImpl implements AdminService { return sendRequest({ abortController, credentials: true, - endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'api', 'scans', scanId]) + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'scans', scanId]) }); } getScanCounts(abortController: AbortController, params?: { dateStartedFrom?: string; dateStartedTo?: string; enforcement?: 'enforced' | 'notEnforced' | 'all'; threatScannerName?: string[]; validationType?: string[] }): Promise> { - const url = new URL(createAbsoluteURL([this.registry.serverUrl, 'admin', 'api', 'scans', 'counts'])); + const query: { key: string, value: string | number }[] = []; if (params) { - if (params.dateStartedFrom) url.searchParams.set('dateStartedFrom', params.dateStartedFrom); - if (params.dateStartedTo) url.searchParams.set('dateStartedTo', params.dateStartedTo); - if (params.enforcement) url.searchParams.set('enforcement', params.enforcement); + if (params.dateStartedFrom) + query.push({ key: 'dateStartedFrom', value: params.dateStartedFrom }); + if (params.dateStartedTo) + query.push({ key: 'dateStartedTo', value: params.dateStartedTo }); + if (params.enforcement) + query.push({ key: 'enforcement', value: params.enforcement }); if (params.threatScannerName && params.threatScannerName.length > 0) { - url.searchParams.set('threatScannerName', params.threatScannerName.join(',')); + query.push({ key: 'threatScannerName', value: params.threatScannerName.join(',') }); } if (params.validationType && params.validationType.length > 0) { - url.searchParams.set('validationType', params.validationType.join(',')); + query.push({ key: 'validationType', value: params.validationType.join(',') }); } } + const endpoint = createAbsoluteURL([this.registry.serverUrl, 'admin', 'scans', 'counts'], query); return sendRequest({ abortController, credentials: true, - endpoint: url.toString() + endpoint }); } @@ -671,41 +681,55 @@ export class AdminServiceImpl implements AdminService { return sendRequest({ abortController, credentials: true, - endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'api', 'scans', 'filterOptions']) + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'scans', 'filterOptions']) }); } getFiles(abortController: AbortController, params?: { size?: number; offset?: number; decision?: string; publisher?: string; namespace?: string; name?: string; dateDecidedFrom?: string; dateDecidedTo?: string; sortBy?: string; sortOrder?: 'asc' | 'desc' }): Promise> { - const url = new URL(createAbsoluteURL([this.registry.serverUrl, 'admin', 'api', 'files'])); + const query: { key: string, value: string | number }[] = []; if (params) { - if (params.size !== undefined) url.searchParams.set('size', String(params.size)); - if (params.offset !== undefined) url.searchParams.set('offset', String(params.offset)); - if (params.decision) url.searchParams.set('decision', params.decision); - if (params.publisher) url.searchParams.set('publisher', params.publisher); - if (params.namespace) url.searchParams.set('namespace', params.namespace); - if (params.name) url.searchParams.set('name', params.name); - if (params.dateDecidedFrom) url.searchParams.set('dateDecidedFrom', params.dateDecidedFrom); - if (params.dateDecidedTo) url.searchParams.set('dateDecidedTo', params.dateDecidedTo); - if (params.sortBy) url.searchParams.set('sortBy', params.sortBy); - if (params.sortOrder) url.searchParams.set('sortOrder', params.sortOrder); + if (params.size !== undefined) + query.push({ key: 'size', value: params.size }); + if (params.offset !== undefined) + query.push({ key: 'offset', value: params.offset }); + if (params.decision) + query.push({ key: 'decision', value: params.decision }); + if (params.publisher) + query.push({ key: 'publisher', value: params.publisher }); + if (params.namespace) + query.push({ key: 'namespace', value: params.namespace }); + if (params.name) + query.push({ key: 'name', value: params.name }); + if (params.dateDecidedFrom) + query.push({ key: 'dateDecidedFrom', value: params.dateDecidedFrom }); + if (params.dateDecidedTo) + query.push({ key: 'dateDecidedTo', value: params.dateDecidedTo }); + if (params.sortBy) + query.push({ key: 'sortBy', value: params.sortBy }); + if (params.sortOrder) + query.push({ key: 'sortOrder', value: params.sortOrder }); } + const endpoint = createAbsoluteURL([this.registry.serverUrl, 'admin', 'scans', 'files'], query); return sendRequest({ abortController, credentials: true, - endpoint: url.toString() + endpoint }); } getFileCounts(abortController: AbortController, params?: { dateDecidedFrom?: string; dateDecidedTo?: string }): Promise> { - const url = new URL(createAbsoluteURL([this.registry.serverUrl, 'admin', 'api', 'files', 'counts'])); + const query: { key: string, value: string | number }[] = []; if (params) { - if (params.dateDecidedFrom) url.searchParams.set('dateDecidedFrom', params.dateDecidedFrom); - if (params.dateDecidedTo) url.searchParams.set('dateDecidedTo', params.dateDecidedTo); + if (params.dateDecidedFrom) + query.push({ key: 'dateDecidedFrom', value: params.dateDecidedFrom }); + if (params.dateDecidedTo) + query.push({ key: 'dateDecidedTo', value: params.dateDecidedTo }); } + const endpoint = createAbsoluteURL([this.registry.serverUrl, 'admin', 'scans', 'files', 'counts'], query); return sendRequest({ abortController, credentials: true, - endpoint: url.toString() + endpoint }); } @@ -723,7 +747,7 @@ export class AdminServiceImpl implements AdminService { abortController, method: 'POST', credentials: true, - endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'api', 'scans', 'decisions']), + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'scans', 'decisions']), headers, payload: request }); @@ -743,7 +767,7 @@ export class AdminServiceImpl implements AdminService { abortController, method: 'POST', credentials: true, - endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'api', 'files', 'decisions']), + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'scans', 'files', 'decisions']), headers, payload: request }); @@ -763,7 +787,7 @@ export class AdminServiceImpl implements AdminService { abortController, method: 'DELETE', credentials: true, - endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'api', 'files', 'decisions']), + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'scans', 'files', 'decisions']), headers, payload: request }); From ecb8e7aa743c3f4a1053d30dc0140f2eb081df6b Mon Sep 17 00:00:00 2001 From: Alejandro Munoz Date: Wed, 14 Jan 2026 01:59:35 -0500 Subject: [PATCH 8/9] Fix scan api tests (#1544) --- .../openvsx/scanning/SecretFinding.java | 2 +- .../eclipse/openvsx/admin/ScanAPITest.java | 28 +++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/SecretFinding.java b/server/src/main/java/org/eclipse/openvsx/scanning/SecretFinding.java index 5413f8614..a4e55c8b6 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/SecretFinding.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/SecretFinding.java @@ -61,7 +61,7 @@ public double getEntropy() { @Override public String toString() { return String.format( - "Potential secret found in %s:%d (rule: %s, entropy: %f): %s", + "Potential secret found in %s:%d (rule: %s, entropy: %.2f): %s", filePath, lineNumber, ruleId, diff --git a/server/src/test/java/org/eclipse/openvsx/admin/ScanAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/ScanAPITest.java index 3d40ceace..ddbdf6c43 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/ScanAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/ScanAPITest.java @@ -84,7 +84,7 @@ void getScans_filters_sorting_and_pagination_are_applied() throws Exception { // Provide display name from linked version Mockito.when(repositories.findVersion("2.0.0", "universal", "third", "gamma")).thenReturn(TestData.version(12, "Alpha Utility")); - mockMvc.perform(get("/admin/api/scans") + mockMvc.perform(get("/admin/scans") .param("status", "VALIDATING") .param("publisher", "alpha") .param("namespace", "a") @@ -123,7 +123,7 @@ void getScans_namespace_partial_match_is_applied() throws Exception { Mockito.when(repositories.findVersion(Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyString())).thenReturn(null); Mockito.when(storageUtil.getFileUrls(Mockito.anyList(), Mockito.anyString(), Mockito.any(), Mockito.any())).thenReturn(Map.of()); - mockMvc.perform(get("/admin/api/scans") + mockMvc.perform(get("/admin/scans") .param("namespace", "alp") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) @@ -156,7 +156,7 @@ void getScans_name_matches_extensionName_and_displayName_partial() throws Except )).thenReturn(new PageImpl<>(List.of(scanA))); // Match by displayName partial (case-insensitive) - mockMvc.perform(get("/admin/api/scans") + mockMvc.perform(get("/admin/scans") .param("name", "tool") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) @@ -171,7 +171,7 @@ void getScans_name_matches_extensionName_and_displayName_partial() throws Except )).thenReturn(new PageImpl<>(List.of(scanB))); // Match by extensionName partial - mockMvc.perform(get("/admin/api/scans") + mockMvc.perform(get("/admin/scans") .param("name", "bet") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) @@ -199,7 +199,7 @@ void getScans_status_supports_comma_separated_values() throws Exception { Mockito.when(storageUtil.getFileUrls(Mockito.anyList(), Mockito.anyString(), Mockito.any(), Mockito.any())).thenReturn(Map.of()); // explode=false behavior: status=PASSED,ERROR should be parsed into a list of two values. - mockMvc.perform(get("/admin/api/scans") + mockMvc.perform(get("/admin/scans") .param("status", "PASSED,ERROR") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) @@ -237,7 +237,7 @@ void getScans_checkType_supports_comma_separated_values() throws Exception { }); // Validates CSV parsing: "BLOCKLIST,NAME SQUATTING" -> ["BLOCKLIST", "NAME SQUATTING"] - mockMvc.perform(get("/admin/api/scans") + mockMvc.perform(get("/admin/scans") .param("validationType", "BLOCKLIST,NAME SQUATTING") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) @@ -251,7 +251,7 @@ void getScanFilterOptions_returns_validationTypes() throws Exception { Mockito.when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); Mockito.when(repositories.findDistinctValidationFailureCheckTypes()).thenReturn(java.util.List.of("NAME_SQUATTING", "BLOCKLIST")); - mockMvc.perform(get("/admin/api/scans/filterOptions").accept(MediaType.APPLICATION_JSON)) + mockMvc.perform(get("/admin/scans/filterOptions").accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.validationTypes.length()").value(2)) .andExpect(jsonPath("$.validationTypes[0]").value("NAME_SQUATTING")) @@ -263,7 +263,7 @@ void getScans_rejects_unknown_sort_field() throws Exception { Mockito.when(admins.checkAdminUser()).thenReturn(TestData.adminUser()); Mockito.when(repositories.findAllExtensionScans()).thenReturn(Streamable.empty()); - mockMvc.perform(get("/admin/api/scans") + mockMvc.perform(get("/admin/scans") .param("sortBy", "unknownField") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isBadRequest()) @@ -283,7 +283,7 @@ void getScanCounts_returns_status_counts_and_zero_decisions() throws Exception { Mockito.when(repositories.countExtensionScansByStatus(ScanStatus.ERRORED)).thenReturn(7L); // Default behavior (no filters): uses the fast count-by-status repository calls. - mockMvc.perform(get("/admin/api/scans/counts").accept(MediaType.APPLICATION_JSON)) + mockMvc.perform(get("/admin/scans/counts").accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.STARTED").value(1)) .andExpect(jsonPath("$.VALIDATING").value(2)) @@ -318,13 +318,13 @@ void getScanCounts_supports_enforcement_filtering() throws Exception { Mockito.argThat(s -> s != ScanStatus.REJECTED), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.anyBoolean() )).thenReturn(0L); - mockMvc.perform(get("/admin/api/scans/counts") + mockMvc.perform(get("/admin/scans/counts") .param("enforcement", "enforced") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.AUTO_REJECTED").value(1)); - mockMvc.perform(get("/admin/api/scans/counts") + mockMvc.perform(get("/admin/scans/counts") .param("enforcement", "notEnforced") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) @@ -349,7 +349,7 @@ void getScans_returns_displayName_from_scan_when_version_missing() throws Except Mockito.when(repositories.findExtensionThreats(Mockito.any())).thenReturn(Streamable.empty()); Mockito.when(storageUtil.getFileUrls(Mockito.anyList(), Mockito.anyString(), Mockito.any(), Mockito.any())).thenReturn(Map.of()); - mockMvc.perform(get("/admin/api/scans").accept(MediaType.APPLICATION_JSON)) + mockMvc.perform(get("/admin/scans").accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.scans[0].displayName").value("Manifest Display")) .andExpect(jsonPath("$.scans[0].extensionName").value("ext")) @@ -361,7 +361,7 @@ void getScans_returns_displayName_from_scan_when_version_missing() throws Except void getScanCounts_requires_admin() throws Exception { Mockito.when(admins.checkAdminUser()).thenThrow(new ErrorResultException("Administration role is required.", HttpStatus.FORBIDDEN)); - mockMvc.perform(get("/admin/api/scans/counts").accept(MediaType.APPLICATION_JSON)) + mockMvc.perform(get("/admin/scans/counts").accept(MediaType.APPLICATION_JSON)) .andExpect(status().isForbidden()); } @@ -369,7 +369,7 @@ void getScanCounts_requires_admin() throws Exception { void getScans_requires_admin() throws Exception { Mockito.when(admins.checkAdminUser()).thenThrow(new ErrorResultException("Administration role is required.", HttpStatus.FORBIDDEN)); - mockMvc.perform(get("/admin/api/scans").accept(MediaType.APPLICATION_JSON)) + mockMvc.perform(get("/admin/scans").accept(MediaType.APPLICATION_JSON)) .andExpect(status().isForbidden()); } From 2f92511c156caadb3e68757adc10de69bf9c6fe6 Mon Sep 17 00:00:00 2001 From: Alejandro N Rivera Date: Wed, 14 Jan 2026 01:59:46 -0500 Subject: [PATCH 9/9] fix admin Extension Scans light theme UI (#1543) --- .../scan-admin/common/file-table.tsx | 4 +-- .../scan-admin/scan-card/scan-card.tsx | 4 +-- .../scan-admin/toolbars/counts-toolbar.tsx | 2 +- .../scan-admin/toolbars/search-toolbar.tsx | 2 +- .../context/scan-admin/scan-api-effects.ts | 1 - webui/src/default/theme.tsx | 36 ++++++++++--------- 6 files changed, 26 insertions(+), 23 deletions(-) diff --git a/webui/src/components/scan-admin/common/file-table.tsx b/webui/src/components/scan-admin/common/file-table.tsx index 8cf852b5a..70bca1f41 100644 --- a/webui/src/components/scan-admin/common/file-table.tsx +++ b/webui/src/components/scan-admin/common/file-table.tsx @@ -274,7 +274,7 @@ export const FileTable: FunctionComponent = (props) => { checked={allSelected} onChange={handleSelectAll} sx={{ - color: theme.palette.scanBackground.light, + color: theme.palette.checkboxUnchecked, '&.Mui-checked': { color: 'secondary.main', }, @@ -433,7 +433,7 @@ export const FileTable: FunctionComponent = (props) => { = ({ position: 'relative', mb: 1.5, borderRadius: 2, - boxShadow: 2, + boxShadow: theme.palette.mode === 'light' ? 4 : 2, backgroundColor: 'transparent', outline: checked ? '2px solid' : 'none', outlineColor: checked ? 'secondary.main' : 'transparent', '&:hover': { - boxShadow: 4, + boxShadow: theme.palette.mode === 'light' ? 6 : 4, }, overflow: 'hidden', paddingLeft: '16px', diff --git a/webui/src/components/scan-admin/toolbars/counts-toolbar.tsx b/webui/src/components/scan-admin/toolbars/counts-toolbar.tsx index c63e24e86..9f6f37d8e 100644 --- a/webui/src/components/scan-admin/toolbars/counts-toolbar.tsx +++ b/webui/src/components/scan-admin/toolbars/counts-toolbar.tsx @@ -173,7 +173,7 @@ export const CountsToolbar: FunctionComponent = ({ checked={option.checked} size='small' sx={{ - color: 'rgba(255, 255, 255, 0.23)', + color: theme.palette.checkboxUnchecked, '&.Mui-checked': { color: theme.palette.secondary.main, }, diff --git a/webui/src/components/scan-admin/toolbars/search-toolbar.tsx b/webui/src/components/scan-admin/toolbars/search-toolbar.tsx index 5397ba8b1..952c79b42 100644 --- a/webui/src/components/scan-admin/toolbars/search-toolbar.tsx +++ b/webui/src/components/scan-admin/toolbars/search-toolbar.tsx @@ -158,7 +158,7 @@ export const SearchToolbar: FunctionComponent = ({ disabled={filter.disabled} size='small' sx={{ - color: 'rgba(255, 255, 255, 0.23)', + color: theme.palette.checkboxUnchecked, '&.Mui-checked': { color: theme.palette.secondary.main, }, diff --git a/webui/src/context/scan-admin/scan-api-effects.ts b/webui/src/context/scan-admin/scan-api-effects.ts index 4812de1e0..dfb766708 100644 --- a/webui/src/context/scan-admin/scan-api-effects.ts +++ b/webui/src/context/scan-admin/scan-api-effects.ts @@ -234,7 +234,6 @@ export const useScansEffect = ( state.dateRange, state.enforcement, state.refreshTrigger, - state.scans.length, dispatch, handleErrorRef, ]); diff --git a/webui/src/default/theme.tsx b/webui/src/default/theme.tsx index 45c63b737..97299f1ef 100644 --- a/webui/src/default/theme.tsx +++ b/webui/src/default/theme.tsx @@ -52,6 +52,7 @@ interface UnenforcedColors { interface CustomPaletteColors { neutral: NeutralColors; textHint: Color; + checkboxUnchecked: Color; passed: StatusColors; quarantined: StatusColors; rejected: StatusColors; @@ -89,35 +90,36 @@ export default function createDefaultTheme(themeType: 'light' | 'dark'): Theme { dark: themeType === 'dark' ? '#151515' : '#fff', }, textHint: 'rgba(0, 0, 0, 0.38)', + checkboxUnchecked: themeType === 'dark' ? 'rgba(255, 255, 255, 0.23)' : 'rgba(0, 0, 0, 0.23)', passed: { - dark: '#2e5c32', - light: '#a5d6a7', + dark: themeType === 'dark' ? '#2e5c32' : '#4db052', + light: themeType === 'dark' ? '#a5d6a7' : '#c8e6c9', }, quarantined: { - dark: '#8e5518', - light: '#ffcc80', + dark: themeType === 'dark' ? '#8e5518' : '#e09030', + light: themeType === 'dark' ? '#ffcc80' : '#ffe0b2', }, rejected: { - dark: '#7d2e2e', - light: '#ef9a9a', + dark: themeType === 'dark' ? '#7d2e2e' : '#d63c3c', + light: themeType === 'dark' ? '#ef9a9a' : '#ffcdd2', }, errorStatus: { - dark: '#5a5a5a', - light: '#b0b0b0', + dark: themeType === 'dark' ? '#5a5a5a' : '#8a8a8a', + light: themeType === 'dark' ? '#b0b0b0' : '#e0e0e0', }, allowed: '#4caf50', blocked: '#f44336', - review: '#ffc107', + review: '#e6a800', selected: { border: '#c160ef', - background: '#3d1b4d', - backgroundHover: '#4d2360', - hover: 'rgba(255, 255, 255, 0.1)', + background: themeType === 'dark' ? '#3d1b4d' : '#f3e5f9', + backgroundHover: themeType === 'dark' ? '#4d2360' : '#e9d5f5', + hover: themeType === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.04)', }, scanBackground: { - default: '#1e1e1e', - light: '#2d2d2d', - dark: '#0a0a0a', + default: themeType === 'dark' ? '#1e1e1e' : '#f5f5f5', + light: themeType === 'dark' ? '#2d2d2d' : '#f0f0f0', + dark: themeType === 'dark' ? '#0a0a0a' : '#fafafa', }, gray: { start: '#888888', @@ -126,7 +128,9 @@ export default function createDefaultTheme(themeType: 'light' | 'dark'): Theme { gradient: 'linear-gradient(90deg, #888888 0%, #cccccc 50%, #888888 100%)', }, unenforced: { - stripe: 'repeating-linear-gradient(-45deg, transparent, transparent 4px, rgba(255, 255, 255, 0.1) 4px, rgba(255, 255, 255, 0.1) 8px)', + stripe: themeType === 'dark' + ? 'repeating-linear-gradient(-45deg, transparent, transparent 4px, rgba(255, 255, 255, 0.12) 4px, rgba(255, 255, 255, 0.12) 8px)' + : 'repeating-linear-gradient(-45deg, transparent, transparent 4px, rgba(0, 0, 0, 0.12) 4px, rgba(0, 0, 0, 0.12) 8px)', }, mode: themeType },