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);
+ }
}