diff --git a/csharp/extractor/Semmle.Extraction.CSharp.DependencyFetching/FeedManager.cs b/csharp/extractor/Semmle.Extraction.CSharp.DependencyFetching/FeedManager.cs index a0b8977ed87a..3a217c246d9b 100644 --- a/csharp/extractor/Semmle.Extraction.CSharp.DependencyFetching/FeedManager.cs +++ b/csharp/extractor/Semmle.Extraction.CSharp.DependencyFetching/FeedManager.cs @@ -33,6 +33,9 @@ internal sealed partial class FeedManager : IDisposable /// public bool CheckNugetFeedResponsiveness { get; } = EnvironmentVariables.GetBooleanOptOut(EnvironmentVariableNames.CheckNugetFeedResponsiveness); + private readonly Lazy> lazyPrivateRegistryUris; + private ImmutableHashSet PrivateRegistryUris => lazyPrivateRegistryUris.Value; + private readonly Lazy> lazyExplicitFeeds; /// @@ -96,6 +99,7 @@ public FeedManager(ILogger logger, IDotNet dotnet, IRegistryProxy? registryProxy this.fileProvider = fileProvider; this.feedManagerIo = feedManagerIo; privateRegistryFeeds = registryProxy?.RegistryURLs ?? []; + lazyPrivateRegistryUris = MakePrivateRegistryUris(); HasPrivateRegistryFeeds = privateRegistryFeeds.Count > 0; privateRegistryBaseFeeds = registryProxy?.RegistryBaseURLs ?? []; hasPrivateRegistryBaseFeeds = privateRegistryBaseFeeds.Count > 0; @@ -123,6 +127,20 @@ public FeedManager(ILogger logger, IDotNet dotnet, IRegistryProxy? registryProxy { } + private Lazy> MakePrivateRegistryUris() => + new Lazy>(() => + { + var uris = ImmutableHashSet.CreateBuilder(); + foreach (var registry in privateRegistryFeeds) + { + if (Uri.TryCreate(registry, UriKind.Absolute, out var uri)) + { + uris.Add(uri); + } + } + return uris.ToImmutable(); + }); + private bool IsNugetOrgFeed(string url) { try @@ -246,6 +264,34 @@ public List MakeRestoreFeeds(string path) return RestoreFeeds(feedsToUse); } + private bool IsPrivateRegistryFeed(string feed) => + privateRegistryFeeds.Contains(feed) || + (Uri.TryCreate(feed, UriKind.Absolute, out var uri) && PrivateRegistryUris.Contains(uri)); + + /// + /// Constructs the NuGet CLI restore sources, excluding private registries that require the registry proxy. + /// An empty list leaves source selection to NuGet only when no filtering or default feeds are needed. + /// + public List MakeNugetExeRestoreFeeds(string path) + { + var feedsToUse = FeedsToUse(path).Where(feed => !IsPrivateRegistryFeed(feed)).ToList(); + var defaultFeeds = (CheckNugetFeedResponsiveness ? ReachableDefaultFeeds : DefaultFeeds) + .Where(feed => !IsPrivateRegistryFeed(feed)).ToList(); + var useDefaultFeeds = feedsToUse.Count == 0 && defaultFeeds.Count > 0; + + if (!CheckNugetFeedResponsiveness && !HasPrivateRegistryFeeds && !useDefaultFeeds) + { + return []; + } + + if (useDefaultFeeds) + { + feedsToUse.AddRange(defaultFeeds); + } + + return RestoreFeeds(feedsToUse); + } + private (int initialTimeout, int tryCount) GetFeedRequestSettings(bool isFallback) { int timeoutMilliSeconds = isFallback && int.TryParse(Environment.GetEnvironmentVariable(EnvironmentVariableNames.NugetFeedResponsivenessInitialTimeoutForFallback), out timeoutMilliSeconds) diff --git a/csharp/extractor/Semmle.Extraction.CSharp.DependencyFetching/PackagesConfigRestorer.cs b/csharp/extractor/Semmle.Extraction.CSharp.DependencyFetching/PackagesConfigRestorer.cs index 2348eb809f8c..f696f584278f 100644 --- a/csharp/extractor/Semmle.Extraction.CSharp.DependencyFetching/PackagesConfigRestorer.cs +++ b/csharp/extractor/Semmle.Extraction.CSharp.DependencyFetching/PackagesConfigRestorer.cs @@ -163,24 +163,8 @@ private bool TryRestoreNugetPackage(string packagesConfig) { logger.LogInfo($"Restoring file \"{packagesConfig}\"..."); - List sourcesArgument = []; - var feedsToUse = feedManager.FeedsToUse(packagesConfig).ToList(); - var defaultFeeds = feedManager.CheckNugetFeedResponsiveness - ? feedManager.ReachableDefaultFeeds - : feedManager.DefaultFeeds; - var useDefaultFeeds = feedsToUse.Count == 0 && defaultFeeds.Count > 0; - - // Explicitly construct the sources to be used for the restore command when checking feed - // responsiveness, using private registries, or falling back to default feeds. - if (feedManager.CheckNugetFeedResponsiveness || feedManager.HasPrivateRegistryFeeds || useDefaultFeeds) - { - if (useDefaultFeeds) - { - feedsToUse.AddRange(defaultFeeds); - } - var restoreFeeds = feedManager.RestoreFeeds(feedsToUse); - sourcesArgument = restoreFeeds.SelectMany(feed => ["-Source", feed]).ToList(); - } + var sourcesArgument = feedManager.MakeNugetExeRestoreFeeds(packagesConfig) + .SelectMany(feed => ["-Source", feed]).ToList(); /* Use nuget.exe to install a package. * Note that there is a clutch of NuGet assemblies which could be used to diff --git a/csharp/extractor/Semmle.Extraction.Tests/FeedManager.cs b/csharp/extractor/Semmle.Extraction.Tests/FeedManager.cs index ae97d2e7e346..0d7e4fccd147 100644 --- a/csharp/extractor/Semmle.Extraction.Tests/FeedManager.cs +++ b/csharp/extractor/Semmle.Extraction.Tests/FeedManager.cs @@ -248,6 +248,159 @@ public void TestFeedsToUse() ], feedsToUse); } + /// + /// Verify that NuGet CLI restore excludes private registries and unreachable feeds, + /// while leaving private registries available to dotnet restore. + /// + [Fact] + public void TestNugetExeRestoreFeedsExcludePrivateRegistries() + { + // Setup + var logger = new LoggerStub(); + var dotnet = new DotNetStub([], [], [], + ["E https://example.com/registry2", "E https://feed.from/folder1", "E https://feed.from/folder2"]); + using var feedManager = new FeedManager(logger, dotnet, new RegistryProxyStub(), + new FileProviderStub(), new FeedManagerIOStub(["https://feed.from/folder2"])); + + // Execute + var feeds = feedManager.MakeNugetExeRestoreFeeds("/path/to/packages.config"); + var dotnetFeeds = feedManager.MakeRestoreFeeds("/path/to/project.csproj"); + + // Verify + Assert.Equal(["https://feed.from/folder1"], feeds); + Assert.Contains("https://example.com/registry2", dotnetFeeds); + } + + /// + /// Verify that NuGet CLI restore excludes private registries that replace NuGet.org, + /// retaining other configured feeds. + /// + [Fact] + public void TestNugetExeRestoreFeedsExcludePrivateBaseRegistries() + { + // Setup + var logger = new LoggerStub(); + var dotnet = new DotNetStub([], [], [], + ["E https://api.nuget.org/v3/index.json", "E https://feed.from/folder1"]); + using var feedManager = new FeedManager(logger, dotnet, new RegistryProxyStubWithBaseUrls(), + new FileProviderStub(), new FeedManagerIOStub([])); + + // Execute + var feeds = feedManager.MakeNugetExeRestoreFeeds("/path/to/packages.config"); + + // Verify + Assert.Equal(["https://feed.from/folder1"], feeds); + } + + /// + /// Verify that NuGet CLI restore uses an empty local source when private registries replace the default feeds, + /// regardless of their reachability. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public void TestNugetExeRestoreFeedsWithOnlyPrivateRegistries(bool unreachable) + { + // Setup + var logger = new LoggerStub(); + var registryProxy = new RegistryProxyStubWithBaseUrls(); + var dotnet = new DotNetStub([], [], [], ["E https://api.nuget.org/v3/index.json"]); + using var feedManager = new FeedManager(logger, dotnet, registryProxy, + new FileProviderStub(), new FeedManagerIOStub(unreachable ? registryProxy.RegistryURLs.ToList() : [])); + + // Execute + var feeds = feedManager.MakeNugetExeRestoreFeeds("/path/to/packages.config"); + + // Verify + var source = Assert.Single(feeds); + Assert.True(Path.IsPathFullyQualified(source)); + Assert.True(Directory.Exists(source)); + } + + /// + /// Verify that NuGet CLI restore explicitly selects reachable feeds when no private registries are configured. + /// + [Fact] + public void TestNugetExeRestoreFeedsWithoutPrivateRegistries() + { + // Setup + var logger = new LoggerStub(); + var dotnet = new DotNetStub([], [], [], ["E https://feed.from/folder1"]); + using var feedManager = new FeedManager(logger, dotnet, null, + new FileProviderStub(), new FeedManagerIOStub([])); + + // Execute + var feeds = feedManager.MakeNugetExeRestoreFeeds("/path/to/packages.config"); + + // Verify + Assert.Equal(["https://feed.from/folder1"], feeds); + } + + /// + /// Verify that NuGet CLI restore falls back to the default NuGet.org feed when no feeds are configured. + /// + [Fact] + public void TestNugetExeRestoreFeedsUseDefaults() + { + // Setup + var logger = new LoggerStub(); + var dotnet = new DotNetStub([], [], [], []); + using var feedManager = new FeedManager(logger, dotnet, null, + new FileProviderStub(), new FeedManagerIOStub([])); + + // Execute + var feeds = feedManager.MakeNugetExeRestoreFeeds("/path/to/packages.config"); + + // Verify + Assert.Equal(["https://api.nuget.org/v3/index.json"], feeds); + } + + /// + /// Verify that non-base private registries do not prevent NuGet CLI restore from falling back to NuGet.org + /// when no usable folder feeds remain. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public void TestNugetExeRestoreFeedsUseDefaultsWithPrivateRegistries(bool unreachableFolderFeed) + { + // Setup + var logger = new LoggerStub(); + var dotnet = new DotNetStub([], [], [], + unreachableFolderFeed ? ["E https://feed.from/folder1"] : []); + using var feedManager = new FeedManager(logger, dotnet, new RegistryProxyStub(), + new FileProviderStub(), new FeedManagerIOStub(["https://feed.from/folder1"])); + + // Execute + var feeds = feedManager.MakeNugetExeRestoreFeeds("/path/to/packages.config"); + + // Verify + Assert.Equal(["https://api.nuget.org/v3/index.json"], feeds); + } + + /// + /// Verify that NuGet CLI restore identifies equivalent private registry URLs regardless of host case + /// or explicit default ports, while preserving case-sensitive registry paths. + /// + [Theory] + [InlineData("https://EXAMPLE.COM/registry2")] + [InlineData("https://example.com:443/registry2")] + [InlineData("https://EXAMPLE.COM:443/registry2")] + public void TestNugetExeRestoreFeedsCompareRegistryUris(string folderFeed) + { + // Setup + var logger = new LoggerStub(); + var dotnet = new DotNetStub([], [], [], [$"E {folderFeed}"]); + using var feedManager = new FeedManager(logger, dotnet, new RegistryProxyStub(), + new FileProviderStub(), new FeedManagerIOStub([])); + + // Execute + var feeds = feedManager.MakeNugetExeRestoreFeeds("/path/to/packages.config"); + + // Verify + Assert.Equal(["https://api.nuget.org/v3/index.json"], feeds); + } + /// /// Verify that `FeedManager` correctly computes the default feeds and reachable default feeds /// when no private registries are configured. diff --git a/csharp/ql/lib/change-notes/2026-10-30-nuget-cli-private-registries.md b/csharp/ql/lib/change-notes/2026-10-30-nuget-cli-private-registries.md new file mode 100644 index 000000000000..4fae6b6ff015 --- /dev/null +++ b/csharp/ql/lib/change-notes/2026-10-30-nuget-cli-private-registries.md @@ -0,0 +1,5 @@ +--- +category: minorAnalysis +--- + +- The C# extractor continues to use the NuGet CLI to restore dependencies from `packages.config` files when private NuGet registries are configured, but excludes those private registries from the CLI's sources. Other applicable feeds remain available, while private registries continue to be used by `dotnet restore`.