Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Expand Up @@ -8,6 +8,13 @@ namespace Microsoft.ComponentDetection.Detectors.Npm.Contracts;
/// </summary>
internal sealed record PackageLockV2Package
{
/// <summary>
/// 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 <c>npm:@scope/name@version</c> syntax.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }

/// <summary>
/// The version found in package.json.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ namespace Microsoft.ComponentDetection.Detectors.Npm.Contracts;
/// </summary>
internal sealed record PackageLockV3Package
{
/// <summary>
/// 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 <c>npm:@scope/name@version</c> syntax.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }

/// <summary>
/// The version found in package.json.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,41 @@ public static IDictionary<string, IDictionary<string, bool>> TryGetAllPackageJso
return returnedDependencies.Concat(AttachDevInformationToDependencies(devDependencies, true)).GroupBy(x => x.Key).ToDictionary(x => x.Key, x => x.First().Value);
}

/// <summary>
/// Tries to parse an npm alias specifier of the form <c>npm:@scope/name@version</c> or <c>npm:name@version</c>.
/// This format appears in the <c>version</c> field of v1/v2 lockfile <c>dependencies</c> entries when a package is aliased.
/// </summary>
/// <param name="versionField">The version field value from the lockfile dependency entry.</param>
/// <param name="packageName">When this method returns <c>true</c>, contains the real package name (e.g. <c>@zkochan/js-yaml</c>).</param>
/// <param name="version">When this method returns <c>true</c>, contains the version string (e.g. <c>0.0.9</c>).</param>
/// <returns><c>true</c> if the value was a valid npm alias specifier; otherwise <c>false</c>.</returns>
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;
}

/// <summary>
/// Gets the module name, stripping off the "node_modules/" prefix if it exists.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading