diff --git a/src/Microsoft.ComponentDetection.Detectors/npm/Contracts/PackageLockV2Package.cs b/src/Microsoft.ComponentDetection.Detectors/npm/Contracts/PackageLockV2Package.cs index 2b2875a5f..309020af8 100644 --- a/src/Microsoft.ComponentDetection.Detectors/npm/Contracts/PackageLockV2Package.cs +++ b/src/Microsoft.ComponentDetection.Detectors/npm/Contracts/PackageLockV2Package.cs @@ -8,6 +8,13 @@ namespace Microsoft.ComponentDetection.Detectors.Npm.Contracts; /// internal sealed record PackageLockV2Package { + /// + /// The package name. This is only present when the package name does not match the folder name in node_modules, + /// for example when a package is aliased via the npm:@scope/name@version syntax. + /// + [JsonPropertyName("name")] + public string? Name { get; init; } + /// /// The version found in package.json. /// diff --git a/src/Microsoft.ComponentDetection.Detectors/npm/Contracts/PackageLockV3Package.cs b/src/Microsoft.ComponentDetection.Detectors/npm/Contracts/PackageLockV3Package.cs index 9bcc7fef0..5239f3652 100644 --- a/src/Microsoft.ComponentDetection.Detectors/npm/Contracts/PackageLockV3Package.cs +++ b/src/Microsoft.ComponentDetection.Detectors/npm/Contracts/PackageLockV3Package.cs @@ -8,6 +8,13 @@ namespace Microsoft.ComponentDetection.Detectors.Npm.Contracts; /// internal sealed record PackageLockV3Package { + /// + /// The package name. This is only present when the package name does not match the folder name in node_modules, + /// for example when a package is aliased via the npm:@scope/name@version syntax. + /// + [JsonPropertyName("name")] + public string? Name { get; init; } + /// /// The version found in package.json. /// diff --git a/src/Microsoft.ComponentDetection.Detectors/npm/NpmComponentDetectorWithRoots.cs b/src/Microsoft.ComponentDetection.Detectors/npm/NpmComponentDetectorWithRoots.cs index f9d529bb0..0cfa0133d 100644 --- a/src/Microsoft.ComponentDetection.Detectors/npm/NpmComponentDetectorWithRoots.cs +++ b/src/Microsoft.ComponentDetection.Detectors/npm/NpmComponentDetectorWithRoots.cs @@ -76,8 +76,16 @@ protected override void ProcessLockfile( while (topLevelDependencies.Count > 0) { var (name, lockDependency, _) = topLevelDependencies.Dequeue(); + var version = lockDependency.Version; - var component = this.CreateComponent(name, lockDependency.Version, lockDependency.Integrity); + // Handle npm aliases like "npm:@zkochan/js-yaml@0.0.9" + if (NpmComponentUtilities.TryParseNpmAlias(version, out var realName, out var realVersion)) + { + name = realName; + version = realVersion; + } + + var component = this.CreateComponent(name, version, lockDependency.Integrity); if (component is null) { continue; @@ -96,8 +104,16 @@ protected override void ProcessLockfile( while (subQueue.Count > 0) { var (subName, subDependency, parentComponent) = subQueue.Dequeue(); + var subVersion = subDependency.Version; + + // Handle npm aliases like "npm:@zkochan/js-yaml@0.0.9" + if (NpmComponentUtilities.TryParseNpmAlias(subVersion, out var realSubName, out var realSubVersion)) + { + subName = realSubName; + subVersion = realSubVersion; + } - var subComponent = this.CreateComponent(subName, subDependency.Version, subDependency.Integrity); + var subComponent = this.CreateComponent(subName, subVersion, subDependency.Integrity); if (subComponent is null || previouslyAddedComponents.Contains(subComponent.Id)) { continue; diff --git a/src/Microsoft.ComponentDetection.Detectors/npm/NpmComponentUtilities.cs b/src/Microsoft.ComponentDetection.Detectors/npm/NpmComponentUtilities.cs index 1df59abec..d4ec137d4 100644 --- a/src/Microsoft.ComponentDetection.Detectors/npm/NpmComponentUtilities.cs +++ b/src/Microsoft.ComponentDetection.Detectors/npm/NpmComponentUtilities.cs @@ -126,6 +126,41 @@ public static IDictionary> TryGetAllPackageJso return returnedDependencies.Concat(AttachDevInformationToDependencies(devDependencies, true)).GroupBy(x => x.Key).ToDictionary(x => x.Key, x => x.First().Value); } + /// + /// Tries to parse an npm alias specifier of the form npm:@scope/name@version or npm:name@version. + /// This format appears in the version field of v1/v2 lockfile dependencies entries when a package is aliased. + /// + /// The version field value from the lockfile dependency entry. + /// When this method returns true, contains the real package name (e.g. @zkochan/js-yaml). + /// When this method returns true, contains the version string (e.g. 0.0.9). + /// true if the value was a valid npm alias specifier; otherwise false. + public static bool TryParseNpmAlias(string? versionField, out string packageName, out string version) + { + packageName = string.Empty; + version = string.Empty; + + if (versionField is null || !versionField.StartsWith("npm:", StringComparison.Ordinal)) + { + return false; + } + + // Remove "npm:" prefix → e.g. "@zkochan/js-yaml@0.0.9" or "ramda@0.28.1" + var specifier = versionField[4..]; + + // Find the last '@' that separates name from version. + // For scoped packages like "@scope/name@version", the first '@' is part of the scope. + var lastAtIndex = specifier.LastIndexOf('@'); + if (lastAtIndex <= 0) + { + return false; + } + + packageName = specifier[..lastAtIndex]; + version = specifier[(lastAtIndex + 1)..]; + + return packageName.Length > 0 && version.Length > 0; + } + /// /// Gets the module name, stripping off the "node_modules/" prefix if it exists. /// diff --git a/src/Microsoft.ComponentDetection.Detectors/npm/NpmLockfile3Detector.cs b/src/Microsoft.ComponentDetection.Detectors/npm/NpmLockfile3Detector.cs index 5fbcdf0fd..8da75d9b0 100644 --- a/src/Microsoft.ComponentDetection.Detectors/npm/NpmLockfile3Detector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/npm/NpmLockfile3Detector.cs @@ -83,7 +83,7 @@ protected override void ProcessLockfile( while (topLevelDependencies.Count > 0) { var (path, lockPackage, _) = topLevelDependencies.Dequeue(); - var name = NpmComponentUtilities.GetModuleName(path); + var name = lockPackage.Name ?? NpmComponentUtilities.GetModuleName(path); var component = this.CreateComponent(name, lockPackage.Version, lockPackage.Integrity); if (component is null) @@ -108,7 +108,7 @@ protected override void ProcessLockfile( while (subQueue.Count > 0) { var (subPath, subPackage, parentComponent) = subQueue.Dequeue(); - var subName = NpmComponentUtilities.GetModuleName(subPath); + var subName = subPackage.Name ?? NpmComponentUtilities.GetModuleName(subPath); var subComponent = this.CreateComponent(subName, subPackage.Version, subPackage.Integrity); if (subComponent is null) diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/NpmDetectorWithRootsTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/NpmDetectorWithRootsTests.cs index 959d5574a..0d7214817 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/NpmDetectorWithRootsTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/NpmDetectorWithRootsTests.cs @@ -696,4 +696,112 @@ public async Task TestNpmDetector_PackageLockMissingDependenciesProperty_ShouldN var detectedComponents = componentRecorder.GetDetectedComponents(); detectedComponents.Should().BeEmpty(); // No dependencies should be detected since dependencies is missing } + + [TestMethod] + public async Task TestNpmDetector_V1Lockfile_AliasedScopedPackages_UsesRealNameAsync() + { + // In v1/v2 lockfiles, aliased packages have a version field like "npm:@scope/name@version" + var packageLockJson = @"{ + ""name"": ""test"", + ""version"": ""0.0.0"", + ""lockfileVersion"": 1, + ""dependencies"": { + ""js-yaml"": { + ""version"": ""npm:@zkochan/js-yaml@0.0.9"", + ""resolved"": ""https://registry.npmjs.org/@zkochan/js-yaml/-/js-yaml-0.0.9.tgz"", + ""integrity"": ""sha512-nAEMjKcB1LDrMyYnjNsDkxoewI2aexrwlT3UJeL+nlbd64FEQNmKgPGAYIieaLVgtpRiHE9OL6/rmHLlstQwnQ=="" + }, + ""ramda"": { + ""version"": ""npm:@pnpm/ramda@0.28.1"", + ""resolved"": ""https://registry.npmjs.org/@pnpm/ramda/-/ramda-0.28.1.tgz"", + ""integrity"": ""sha512-W86pkk7P9PAfARThHaD4fIjJ8QJUGMB2OhlCFsrueciPqlYZvDg/w62BmRm7PghVQcxGLbYoPN4+iykzP+0jRQ=="" + } + } + }"; + + var packageJson = @"{ + ""name"": ""test"", + ""version"": ""0.0.0"", + ""dependencies"": { + ""js-yaml"": ""npm:@zkochan/js-yaml@0.0.9"", + ""ramda"": ""npm:@pnpm/ramda@0.28.1"" + } + }"; + + var (scanResult, componentRecorder) = await this.detectorTestUtility + .WithFile(this.packageLockJsonFileName, packageLockJson, this.packageLockJsonSearchPatterns) + .WithFile(this.packageJsonFileName, packageJson, this.packageJsonSearchPattern) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var detectedComponents = componentRecorder.GetDetectedComponents().ToList(); + detectedComponents.Should().HaveCount(2); + + // Verify the real scoped package names are used, not the alias names + var jsYaml = detectedComponents.First(c => ((NpmComponent)c.Component).Name == "@zkochan/js-yaml"); + ((NpmComponent)jsYaml.Component).Version.Should().Be("0.0.9"); + + var ramda = detectedComponents.First(c => ((NpmComponent)c.Component).Name == "@pnpm/ramda"); + ((NpmComponent)ramda.Component).Version.Should().Be("0.28.1"); + + // Ensure the alias names are NOT used + detectedComponents.Should().NotContain(c => ((NpmComponent)c.Component).Name == "js-yaml"); + detectedComponents.Should().NotContain(c => ((NpmComponent)c.Component).Name == "ramda"); + } + + [TestMethod] + public async Task TestNpmDetector_V1Lockfile_AliasedScopedPackageAsTransitiveDependency_UsesRealNameAsync() + { + // Tests that aliased scoped packages are correctly detected as transitive dependencies in v1/v2 lockfiles + var packageLockJson = @"{ + ""name"": ""test"", + ""version"": ""0.0.0"", + ""lockfileVersion"": 1, + ""dependencies"": { + ""my-package"": { + ""version"": ""1.0.0"", + ""resolved"": ""https://registry.npmjs.org/my-package/-/my-package-1.0.0.tgz"", + ""integrity"": ""sha512-nAEMjKcB1LDrMyYnjNsDkxoewI2aexrwlT3UJeL+nlbd64FEQNmKgPGAYIieaLVgtpRiHE9OL6/rmHLlstQwnQ=="", + ""requires"": { + ""js-yaml"": ""npm:@zkochan/js-yaml@0.0.9"" + } + }, + ""js-yaml"": { + ""version"": ""npm:@zkochan/js-yaml@0.0.9"", + ""resolved"": ""https://registry.npmjs.org/@zkochan/js-yaml/-/js-yaml-0.0.9.tgz"", + ""integrity"": ""sha512-W86pkk7P9PAfARThHaD4fIjJ8QJUGMB2OhlCFsrueciPqlYZvDg/w62BmRm7PghVQcxGLbYoPN4+iykzP+0jRQ=="" + } + } + }"; + + var packageJson = @"{ + ""name"": ""test"", + ""version"": ""0.0.0"", + ""dependencies"": { + ""my-package"": ""1.0.0"" + } + }"; + + var (scanResult, componentRecorder) = await this.detectorTestUtility + .WithFile(this.packageLockJsonFileName, packageLockJson, this.packageLockJsonSearchPatterns) + .WithFile(this.packageJsonFileName, packageJson, this.packageJsonSearchPattern) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var detectedComponents = componentRecorder.GetDetectedComponents().ToList(); + detectedComponents.Should().HaveCount(2); + + // The aliased transitive dependency should use the real scoped name + var jsYaml = detectedComponents.First(c => ((NpmComponent)c.Component).Name == "@zkochan/js-yaml"); + ((NpmComponent)jsYaml.Component).Version.Should().Be("0.0.9"); + + // The alias name should NOT be present + detectedComponents.Should().NotContain(c => ((NpmComponent)c.Component).Name == "js-yaml"); + + // my-package should be detected normally + var myPackage = detectedComponents.First(c => ((NpmComponent)c.Component).Name == "my-package"); + ((NpmComponent)myPackage.Component).Version.Should().Be("1.0.0"); + } } diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/NpmLockfile3DetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/NpmLockfile3DetectorTests.cs index 6f2f62921..d9249bb02 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/NpmLockfile3DetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/NpmLockfile3DetectorTests.cs @@ -598,4 +598,127 @@ public async Task TestNpmDetector_CircularDependency_HandledGracefullyAsync() componentCId, parentComponent => parentComponent.Name == componentA.Name).Should().BeTrue(); } + + [TestMethod] + public async Task TestNpmDetector_PackageLockVersion3_AliasedScopedPackages_UsesRealNameAsync() + { + // Simulates npm aliases like "js-yaml": "npm:@zkochan/js-yaml@0.0.9" + // In the lockfile, the path key uses the alias name but the "name" field contains the real scoped package name. + var packageLockJson = @"{ + ""name"": ""test"", + ""version"": ""0.0.0"", + ""lockfileVersion"": 3, + ""packages"": { + """": { + ""dependencies"": { + ""js-yaml"": ""0.0.9"", + ""ramda"": ""0.28.1"" + } + }, + ""node_modules/js-yaml"": { + ""name"": ""@zkochan/js-yaml"", + ""version"": ""0.0.9"", + ""resolved"": ""https://registry.npmjs.org/@zkochan/js-yaml/-/js-yaml-0.0.9.tgz"", + ""integrity"": ""sha512-nAEMjKcB1LDrMyYnjNsDkxoewI2aexrwlT3UJeL+nlbd64FEQNmKgPGAYIieaLVgtpRiHE9OL6/rmHLlstQwnQ=="" + }, + ""node_modules/ramda"": { + ""name"": ""@pnpm/ramda"", + ""version"": ""0.28.1"", + ""resolved"": ""https://registry.npmjs.org/@pnpm/ramda/-/ramda-0.28.1.tgz"", + ""integrity"": ""sha512-W86pkk7P9PAfARThHaD4fIjJ8QJUGMB2OhlCFsrueciPqlYZvDg/w62BmRm7PghVQcxGLbYoPN4+iykzP+0jRQ=="" + } + } + }"; + + var packageJson = @"{ + ""name"": ""test"", + ""version"": ""0.0.0"", + ""dependencies"": { + ""js-yaml"": ""npm:@zkochan/js-yaml@0.0.9"", + ""ramda"": ""npm:@pnpm/ramda@0.28.1"" + } + }"; + + var (scanResult, componentRecorder) = await this.detectorTestUtility + .WithFile(this.packageLockJsonFileName, packageLockJson, this.packageLockJsonSearchPatterns) + .WithFile(this.packageJsonFileName, packageJson, this.packageJsonSearchPattern) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var detectedComponents = componentRecorder.GetDetectedComponents().ToList(); + detectedComponents.Should().HaveCount(2); + + // Verify the real scoped package names are used, not the alias names + var jsYaml = detectedComponents.First(c => ((NpmComponent)c.Component).Name == "@zkochan/js-yaml"); + ((NpmComponent)jsYaml.Component).Version.Should().Be("0.0.9"); + + var ramda = detectedComponents.First(c => ((NpmComponent)c.Component).Name == "@pnpm/ramda"); + ((NpmComponent)ramda.Component).Version.Should().Be("0.28.1"); + + // Ensure the alias names are NOT used + detectedComponents.Should().NotContain(c => ((NpmComponent)c.Component).Name == "js-yaml"); + detectedComponents.Should().NotContain(c => ((NpmComponent)c.Component).Name == "ramda"); + } + + [TestMethod] + public async Task TestNpmDetector_PackageLockVersion3_AliasedScopedPackageAsTransitiveDependency_UsesRealNameAsync() + { + // Tests that aliased scoped packages are correctly detected when they appear as transitive dependencies + var packageLockJson = @"{ + ""name"": ""test"", + ""version"": ""0.0.0"", + ""lockfileVersion"": 3, + ""packages"": { + """": { + ""dependencies"": { + ""my-package"": ""1.0.0"" + } + }, + ""node_modules/my-package"": { + ""version"": ""1.0.0"", + ""resolved"": ""https://registry.npmjs.org/my-package/-/my-package-1.0.0.tgz"", + ""integrity"": ""sha512-nAEMjKcB1LDrMyYnjNsDkxoewI2aexrwlT3UJeL+nlbd64FEQNmKgPGAYIieaLVgtpRiHE9OL6/rmHLlstQwnQ=="", + ""dependencies"": { + ""js-yaml"": ""0.0.9"" + } + }, + ""node_modules/js-yaml"": { + ""name"": ""@zkochan/js-yaml"", + ""version"": ""0.0.9"", + ""resolved"": ""https://registry.npmjs.org/@zkochan/js-yaml/-/js-yaml-0.0.9.tgz"", + ""integrity"": ""sha512-W86pkk7P9PAfARThHaD4fIjJ8QJUGMB2OhlCFsrueciPqlYZvDg/w62BmRm7PghVQcxGLbYoPN4+iykzP+0jRQ=="" + } + } + }"; + + var packageJson = @"{ + ""name"": ""test"", + ""version"": ""0.0.0"", + ""dependencies"": { + ""my-package"": ""1.0.0"" + } + }"; + + var (scanResult, componentRecorder) = await this.detectorTestUtility + .WithFile(this.packageLockJsonFileName, packageLockJson, this.packageLockJsonSearchPatterns) + .WithFile(this.packageJsonFileName, packageJson, this.packageJsonSearchPattern) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var detectedComponents = componentRecorder.GetDetectedComponents().ToList(); + detectedComponents.Should().HaveCount(2); + + // The aliased transitive dependency should use the real scoped name + var jsYaml = detectedComponents.First(c => ((NpmComponent)c.Component).Name == "@zkochan/js-yaml"); + ((NpmComponent)jsYaml.Component).Version.Should().Be("0.0.9"); + + // The alias name should NOT be present + detectedComponents.Should().NotContain(c => ((NpmComponent)c.Component).Name == "js-yaml"); + + // my-package should be detected normally + var myPackage = detectedComponents.First(c => ((NpmComponent)c.Component).Name == "my-package"); + ((NpmComponent)myPackage.Component).Version.Should().Be("1.0.0"); + } } diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/NpmUtilitiesTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/NpmUtilitiesTests.cs index f3e89bccd..7afc2eaae 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/NpmUtilitiesTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/NpmUtilitiesTests.cs @@ -234,4 +234,22 @@ public void GetModuleName_ReturnsAsExpected() NpmComponentUtilities.GetModuleName(path).Should().Be(expectedModuleName); } } + + [TestMethod] + [DataRow("npm:@zkochan/js-yaml@0.0.9", true, "@zkochan/js-yaml", "0.0.9")] + [DataRow("npm:@pnpm/ramda@0.28.1", true, "@pnpm/ramda", "0.28.1")] + [DataRow("npm:lodash@4.17.21", true, "lodash", "4.17.21")] + [DataRow("1.2.3", false, "", "")] + [DataRow("^1.0.0", false, "", "")] + [DataRow(null, false, "", "")] + [DataRow("npm:", false, "", "")] + [DataRow("npm:@scope", false, "", "")] + [DataRow("npm:name", false, "", "")] + public void TryParseNpmAlias_ReturnsAsExpected(string input, bool expectedResult, string expectedName, string expectedVersion) + { + var result = NpmComponentUtilities.TryParseNpmAlias(input, out var packageName, out var version); + result.Should().Be(expectedResult); + packageName.Should().Be(expectedName); + version.Should().Be(expectedVersion); + } }