From 714f8b23f64a8bb4db522c75503d18863fd4e0e7 Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Wed, 25 Mar 2026 14:11:44 -0700 Subject: [PATCH] Include distro version in Linux purls, add Alpine support LinuxComponent.PackageUrl was missing the distro release -- so pkg:deb/ubuntu/bash@1 on 18.04 and 20.04 produced identical purls even though ComputeBaseId distinguished them. Now emits a distro qualifier (e.g., distro=ubuntu-20.04) to match what Syft produces. Also adds apk as a purl type for Alpine, and maps Red Hat Enterprise Linux to the short id edhat for both namespace and qualifier. Fixes #1714 --- .../TypedComponent/LinuxComponent.cs | 28 +++++++++++++++- .../PurlGenerationTests.cs | 33 ++++++++++++++++--- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.ComponentDetection.Contracts/TypedComponent/LinuxComponent.cs b/src/Microsoft.ComponentDetection.Contracts/TypedComponent/LinuxComponent.cs index f95501ec2..db286d69d 100644 --- a/src/Microsoft.ComponentDetection.Contracts/TypedComponent/LinuxComponent.cs +++ b/src/Microsoft.ComponentDetection.Contracts/TypedComponent/LinuxComponent.cs @@ -2,6 +2,7 @@ namespace Microsoft.ComponentDetection.Contracts.TypedComponent; using System; +using System.Collections.Generic; using System.Text.Json.Serialization; using PackageUrl; @@ -60,10 +61,20 @@ public override PackageUrl PackageUrl { packageType = "rpm"; } + else if (this.IsAlpine()) + { + packageType = "apk"; + } if (packageType != null) { - return new PackageUrl(packageType, this.Distribution, this.Name, this.Version, null, null); + var distroId = this.GetDistroId(); + var qualifiers = new SortedDictionary + { + { "distro", $"{distroId}-{this.Release}" }, + }; + + return new PackageUrl(packageType, distroId, this.Name, this.Version, qualifiers, null); } return null; @@ -96,4 +107,19 @@ private bool IsRHEL() { return this.Distribution.Equals("RED HAT ENTERPRISE LINUX", StringComparison.OrdinalIgnoreCase); } + + private bool IsAlpine() + { + return this.Distribution.Equals("ALPINE", StringComparison.OrdinalIgnoreCase); + } + + private string GetDistroId() + { + if (this.IsRHEL()) + { + return "redhat"; + } + + return this.Distribution.ToLowerInvariant(); + } } diff --git a/test/Microsoft.ComponentDetection.Contracts.Tests/PurlGenerationTests.cs b/test/Microsoft.ComponentDetection.Contracts.Tests/PurlGenerationTests.cs index 18b9f99c4..b2313bd21 100644 --- a/test/Microsoft.ComponentDetection.Contracts.Tests/PurlGenerationTests.cs +++ b/test/Microsoft.ComponentDetection.Contracts.Tests/PurlGenerationTests.cs @@ -47,6 +47,9 @@ public void DebianAndUbuntuAreDebType() ubuntuComponent.PackageUrl.Type.Should().Be("deb"); debianComponent.PackageUrl.Type.Should().Be("deb"); + + ubuntuComponent.PackageUrl.Qualifiers["distro"].Should().Be("ubuntu-18.04"); + debianComponent.PackageUrl.Qualifiers["distro"].Should().Be("debian-buster"); } [TestMethod] @@ -61,17 +64,29 @@ public void CentOsFedoraAndRHELAreRpmType() centosComponent.PackageUrl.Type.Should().Be("rpm"); fedoraComponent.PackageUrl.Type.Should().Be("rpm"); rhelComponent.PackageUrl.Type.Should().Be("rpm"); + + centosComponent.PackageUrl.Qualifiers["distro"].Should().Be("centos-18.04"); + fedoraComponent.PackageUrl.Qualifiers["distro"].Should().Be("fedora-18.04"); + rhelComponent.PackageUrl.Qualifiers["distro"].Should().Be("redhat-18.04"); } [TestMethod] - public void AlpineAndUnknownDoNotHavePurls() + public void AlpineIsApkType() { - // Alpine is not yet defined - // https://github.com/package-url/purl-spec/blame/180c46d266c45aa2bd81a2038af3f78e87bb4a25/README.rst#L711 + // Alpine uses "apk" purl type + // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#apk var alpineComponent = new LinuxComponent("Alpine", "3.13", "bash", "1"); + + alpineComponent.PackageUrl.Type.Should().Be("apk"); + alpineComponent.PackageUrl.Namespace.Should().Be("alpine"); + alpineComponent.PackageUrl.Qualifiers["distro"].Should().Be("alpine-3.13"); + } + + [TestMethod] + public void UnknownDistroDoesNotHavePurl() + { var unknownLinuxComponent = new LinuxComponent("Linux", "0", "bash", "1'"); - alpineComponent.PackageUrl.Should().BeNull(); unknownLinuxComponent.PackageUrl.Should().BeNull(); } @@ -88,6 +103,16 @@ public void DistroNamesAreLowerCased() fedoraComponent.PackageUrl.Namespace.Should().Be("fedora"); } + [TestMethod] + public void RhelNamespaceIsRedhat() + { + // RHEL should use "redhat" as the namespace and distro id, matching Syft conventions + var rhelComponent = new LinuxComponent("Red Hat Enterprise Linux", "9.0", "bash", "1"); + + rhelComponent.PackageUrl.Namespace.Should().Be("redhat"); + rhelComponent.PackageUrl.Qualifiers["distro"].Should().Be("redhat-9.0"); + } + [TestMethod] public void CocoaPodNameShouldSupportPurl() {