Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/********************************************************************************
* 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 java.util.List;

import jakarta.annotation.PostConstruct;
import jakarta.persistence.EntityManager;
import org.jspecify.annotations.NonNull;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import org.eclipse.openvsx.adapter.VSCodeIdService;
import org.eclipse.openvsx.entities.ExtensionVersion;
import org.eclipse.openvsx.repositories.RepositoryService;
import org.eclipse.openvsx.util.NamingUtil;

/**
* Scanner that blocks publishing to a namespace/extension identifier that already exists on the
* upstream VS Code Marketplace, unless the publishing user is a local owner (not just a
* contributor) of the namespace. Guards against namespace-squatting relative to the upstream
* gallery identity.
*/
@Component
public class VSCodeGalleryOwnershipScanner implements Scanner {

public static final String TYPE = "vscode-gallery-ownership";

private final VSCodeIdService vsCodeIdService;
private final RepositoryService repositories;
private final EntityManager entityManager;
private final ScannerRegistry scannerRegistry;

@Value("${ovsx.scanning.gallery-ownership.enabled:false}")
private boolean enabled;
@Value("${ovsx.scanning.gallery-ownership.required:true}")
private boolean required;
@Value("${ovsx.scanning.gallery-ownership.enforced:true}")
private boolean enforced;

public VSCodeGalleryOwnershipScanner(
VSCodeIdService vsCodeIdService,
RepositoryService repositories,
EntityManager entityManager,
ScannerRegistry scannerRegistry
) {
this.vsCodeIdService = vsCodeIdService;
this.repositories = repositories;
this.entityManager = entityManager;
this.scannerRegistry = scannerRegistry;
}

@PostConstruct
void register() {
if (enabled) {
scannerRegistry.registerScanner(this);
}
}

@Override
@NonNull
public String getScannerType() {
return TYPE;
}

@Override
public boolean isRequired() {
return required;
}

@Override
public boolean enforcesThreats() {
return enforced;
}

@Override
public boolean isAsync() {
return false;
}

@Override
public Scanner.@NonNull Invocation startScan(@NonNull Command command) throws ScannerException {
var extVersion = entityManager.find(ExtensionVersion.class, command.extensionVersionId());
if (extVersion == null) {
throw new ScannerException("ExtensionVersion not found: " + command.extensionVersionId());
}

var extension = extVersion.getExtension();
var namespace = extension.getNamespace();

var upstream = vsCodeIdService.getUpstreamPublicIds(extension);
Comment thread
netomi marked this conversation as resolved.
Outdated
boolean existsUpstream = upstream != null && upstream.namespace() != null && upstream.extension() != null;
if (!existsUpstream) {
return new Scanner.Invocation.Completed(Scanner.Result.clean());
}

var publishedWith = extVersion.getPublishedWith();
var user = publishedWith != null ? publishedWith.getUser() : null;
if (user != null && repositories.isVerified(namespace, user)) {
return new Scanner.Invocation.Completed(
Scanner.Result.clean(
"Extension exists on the VS Code Marketplace; namespace confirmed as verified."));
}

var threat = new Scanner.Threat(
"vscode-gallery-namespace-conflict",
"'" + NamingUtil.toExtensionId(extension) + "' already exists on the VS Code Marketplace, " +
"and the publishing user is not an owner of namespace '" + namespace.getName() + "'.",
"high");
return new Scanner.Invocation.Completed(Scanner.Result.withThreats(List.of(threat)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/********************************************************************************
* 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.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import org.eclipse.openvsx.adapter.PublicIds;
import org.eclipse.openvsx.adapter.VSCodeIdService;
import org.eclipse.openvsx.entities.Extension;
import org.eclipse.openvsx.entities.ExtensionVersion;
import org.eclipse.openvsx.entities.Namespace;
import org.eclipse.openvsx.entities.PersonalAccessToken;
import org.eclipse.openvsx.entities.UserData;
import org.eclipse.openvsx.repositories.RepositoryService;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class VSCodeGalleryOwnershipScannerTest {

@Mock
private VSCodeIdService vsCodeIdService;
@Mock
private RepositoryService repositories;
@Mock
private EntityManager entityManager;
@Mock
private ScannerRegistry scannerRegistry;

private VSCodeGalleryOwnershipScanner newScanner() {
return new VSCodeGalleryOwnershipScanner(vsCodeIdService, repositories, entityManager, scannerRegistry);
}

private ExtensionVersion extensionVersion(UserData publisher) {
var namespace = new Namespace();
namespace.setName("acme");

var extension = new Extension();
extension.setName("widget");
extension.setNamespace(namespace);

var extVersion = new ExtensionVersion();
extVersion.setExtension(extension);
if (publisher != null) {
var token = new PersonalAccessToken();
token.setUser(publisher);
extVersion.setPublishedWith(token);
}
return extVersion;
}

@Test
void startScan_isClean_whenExtensionDoesNotExistUpstream() throws Exception {
var extVersion = extensionVersion(new UserData());
when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion);
when(vsCodeIdService.getUpstreamPublicIds(any())).thenReturn(new PublicIds(null, null));

var invocation = (Scanner.Invocation.Completed) newScanner().startScan(new Scanner.Command(1L, "scan-1"));

assertTrue(invocation.result().isClean());
verify(repositories, never()).isVerified(any(), any());
}

@Test
void startScan_raisesThreat_whenExistsUpstreamAndNamespaceIsNotVerified() throws Exception {
var user = new UserData();
var extVersion = extensionVersion(user);
when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion);
when(vsCodeIdService.getUpstreamPublicIds(any())).thenReturn(new PublicIds("acme-pub-id", "widget-pub-id"));
when(repositories.isVerified(extVersion.getExtension().getNamespace(), user)).thenReturn(false);

var invocation = (Scanner.Invocation.Completed) newScanner().startScan(new Scanner.Command(1L, "scan-1"));

assertFalse(invocation.result().isClean());
assertEquals(1, invocation.result().getThreats().size());
}

@Test
void startScan_raisesThreat_whenExistsUpstreamAndNamespaceIsVerified() throws Exception {
var user = new UserData();
var extVersion = extensionVersion(user);
when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion);
when(vsCodeIdService.getUpstreamPublicIds(any())).thenReturn(new PublicIds("acme-pub-id", "widget-pub-id"));
when(repositories.isVerified(extVersion.getExtension().getNamespace(), user)).thenReturn(true);

var invocation = (Scanner.Invocation.Completed) newScanner().startScan(new Scanner.Command(1L, "scan-1"));

assertTrue(invocation.result().isClean());
assertEquals(0, invocation.result().getThreats().size());
}

@Test
void startScan_raisesThreat_whenExistsUpstreamAndNoPublishingUserIsAttributed() throws Exception {
var extVersion = extensionVersion(null);
when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion);
when(vsCodeIdService.getUpstreamPublicIds(any())).thenReturn(new PublicIds("acme-pub-id", "widget-pub-id"));

var invocation = (Scanner.Invocation.Completed) newScanner().startScan(new Scanner.Command(1L, "scan-1"));

assertFalse(invocation.result().isClean());
verify(repositories, never()).isVerified(any(), any());
}

@Test
void startScan_throws_whenExtensionVersionNotFound() {
when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(null);

assertThrows(ScannerException.class, () -> newScanner().startScan(new Scanner.Command(1L, "scan-1")));
}
}