diff --git a/pkg/build/build_integration_test.go b/pkg/build/build_integration_test.go index 9324c6423..84a6ebba7 100644 --- a/pkg/build/build_integration_test.go +++ b/pkg/build/build_integration_test.go @@ -35,6 +35,10 @@ func TestBuild_BuildPackage(t *testing.T) { name: "7zip-two-fetches", expectedVersion: "2301-r3", }, + { + name: "git-checkout-cherry-pick", + expectedVersion: "1.2-r1", + }, { name: "bogus-version", expectedVersion: "1.0.0_b6", diff --git a/pkg/build/testdata/build_configs/git-checkout-cherry-pick.yaml b/pkg/build/testdata/build_configs/git-checkout-cherry-pick.yaml new file mode 100644 index 000000000..3cc95854e --- /dev/null +++ b/pkg/build/testdata/build_configs/git-checkout-cherry-pick.yaml @@ -0,0 +1,34 @@ +package: + name: git-checkout-cherry-pick + version: 1.2 + epoch: 1 + description: Test package for cherry-pick SBOM generation + copyright: + - license: Apache-2.0 + checks: + disabled: + - empty + +environment: + contents: + keyring: + - https://packages.wolfi.dev/os/wolfi-signing.rsa.pub + repositories: + - https://packages.wolfi.dev/os + packages: + - busybox + +pipeline: + - uses: git-checkout + with: + repository: https://github.com/chainguard-dev/hello-world-golang.git + tag: v${{package.version}} + expected-commit: bade10b96289e0c108ef6f7ea26be293c4946299 + cherry-picks: | + main/618bb31108414cb031a29e6ca521e1192079c1af: Update README and add license + main/3918c15bbe67b08ccf7a3027b1fba3f1518cf328: Update clone url in readme + + - runs: | + # Create a simple test file to make the package non-empty + mkdir -p ${{targets.destdir}}/usr/share/hello + cp README.md ${{targets.destdir}}/usr/share/hello/ || echo "README created from cherry-picks" > ${{targets.destdir}}/usr/share/hello/README.md diff --git a/pkg/build/testdata/goldenfiles/sboms/git-checkout-cherry-pick-1.2-r1.spdx.json b/pkg/build/testdata/goldenfiles/sboms/git-checkout-cherry-pick-1.2-r1.spdx.json new file mode 100644 index 000000000..84f48e7b8 --- /dev/null +++ b/pkg/build/testdata/goldenfiles/sboms/git-checkout-cherry-pick-1.2-r1.spdx.json @@ -0,0 +1,110 @@ +{ + "SPDXID": "SPDXRef-DOCUMENT", + "name": "apk-git-checkout-cherry-pick-1.2-r1", + "spdxVersion": "SPDX-2.3", + "creationInfo": { + "created": "0001-01-01T00:00:00Z", + "creators": [ + "Tool: melange (devel)", + "Organization: Chainguard, Inc" + ], + "licenseListVersion": "3.22" + }, + "dataLicense": "CC0-1.0", + "documentNamespace": "https://spdx.org/spdxdocs/chainguard/melange/db5631710be34672aba80a8b7f2c4f4d", + "documentDescribes": [ + "SPDXRef-Package-git-checkout-cherry-pick-1.2-r1" + ], + "packages": [ + { + "SPDXID": "SPDXRef-OperatingSystem", + "name": "wolfi", + "versionInfo": "20230201", + "filesAnalyzed": false, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "description": "Operating System", + "downloadLocation": "NOASSERTION", + "originator": "Organization: Wolfi", + "supplier": "Organization: Wolfi", + "primaryPackagePurpose": "OPERATING-SYSTEM" + }, + { + "SPDXID": "SPDXRef-Package-git-checkout-cherry-pick-1.2-r1", + "name": "git-checkout-cherry-pick", + "versionInfo": "1.2-r1", + "filesAnalyzed": false, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "Apache-2.0", + "downloadLocation": "NOASSERTION", + "originator": "Organization: Wolfi", + "supplier": "Organization: Wolfi", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceLocator": "pkg:apk/wolfi/git-checkout-cherry-pick@1.2-r1?arch=x86_64\u0026distro=wolfi", + "referenceType": "purl" + } + ] + }, + { + "SPDXID": "SPDXRef-Package-testdata-buildC95configs-git-checkout-cherry-pick.yaml-c0ffee", + "name": "testdata/build_configs/git-checkout-cherry-pick.yaml", + "versionInfo": "c0ffee", + "filesAnalyzed": false, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "originator": "Organization: Wolfi", + "supplier": "Organization: Wolfi", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceLocator": "pkg:github/wolfi-dev/os@c0ffee#testdata/build_configs/git-checkout-cherry-pick.yaml", + "referenceType": "purl" + } + ] + }, + { + "SPDXID": "SPDXRef-Package-github.com-chainguard-dev-hello-world-golang.git-v1.2-bade10b96289e0c108ef6f7ea26be293c4946299-0", + "name": "hello-world-golang", + "versionInfo": "v1.2", + "filesAnalyzed": false, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "Apache-2.0", + "downloadLocation": "https://github.com/chainguard-dev/hello-world-golang/archive/bade10b96289e0c108ef6f7ea26be293c4946299.tar.gz", + "originator": "Organization: Chainguard-Dev", + "supplier": "Organization: Chainguard-Dev", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceLocator": "pkg:github/chainguard-dev/hello-world-golang@v1.2", + "referenceType": "purl" + }, + { + "referenceCategory": "OTHER", + "referenceLocator": "main/618bb31108414cb031a29e6ca521e1192079c1af", + "referenceType": "cherry-pick" + }, + { + "referenceCategory": "OTHER", + "referenceLocator": "main/3918c15bbe67b08ccf7a3027b1fba3f1518cf328", + "referenceType": "cherry-pick" + } + ] + } + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-Package-git-checkout-cherry-pick-1.2-r1", + "relationshipType": "DESCRIBED_BY", + "relatedSpdxElement": "SPDXRef-Package-testdata-buildC95configs-git-checkout-cherry-pick.yaml-c0ffee" + }, + { + "spdxElementId": "SPDXRef-Package-git-checkout-cherry-pick-1.2-r1", + "relationshipType": "GENERATED_FROM", + "relatedSpdxElement": "SPDXRef-Package-github.com-chainguard-dev-hello-world-golang.git-v1.2-bade10b96289e0c108ef6f7ea26be293c4946299-0" + } + ] +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 50a9d0ce3..92d68cc03 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -36,6 +36,7 @@ import ( "time" apko_types "chainguard.dev/apko/pkg/build/types" + "chainguard.dev/apko/pkg/sbom/generator/spdx" purl "github.com/package-url/packageurl-go" "chainguard.dev/melange/pkg/sbom" @@ -543,10 +544,52 @@ func SHA256(text string) string { return hex.EncodeToString(algorithm.Sum(nil)) } +// parseCherryPicksToExternalRefs parses cherry-pick entries and converts them +// to SPDX ExternalRef entries with category "OTHER" and type "cherry-pick". +// The format expected is: [branch/]commit-id: comment +// Each cherry-pick is represented as an ExternalRef with the locator being the +// branch/commit format +func parseCherryPicksToExternalRefs(cherryPicks string) []spdx.ExternalRef { + lines := strings.Split(cherryPicks, "\n") + refs := make([]spdx.ExternalRef, 0, len(lines)) + for _, line := range lines { + line = strings.Split(line, "#")[0] + line = strings.TrimSpace(line) + if line == "" { + continue + } + + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + // Invalid format, skip this line + continue + } + + locator := strings.TrimSpace(parts[0]) + comment := strings.TrimSpace(parts[1]) + if locator == "" || comment == "" { + continue + } + + // Create an ExternalRef entry + // Note: The comment is currently not included as apko's ExternalRef + // struct doesn't have a Comment field yet. Once that's added, we should + // include it here. + refs = append(refs, spdx.ExternalRef{ + Category: "OTHER", + Type: "cherry-pick", + Locator: locator, + // Comment: comment, // TODO: Uncomment once apko supports this field + }) + } + + return refs +} + // getGitSBOMPackage creates an SBOM package for Git based repositories. // Returns nil package and nil error if the repository is not from a supported platform or // if neither a tag of expectedCommit is not provided -func getGitSBOMPackage(repo, tag, expectedCommit string, idComponents []string, licenseDeclared, hint, supplier string) (*sbom.Package, error) { +func getGitSBOMPackage(repo, tag, expectedCommit, cherryPicks string, idComponents []string, licenseDeclared, hint, supplier string) (*sbom.Package, error) { var repoType, namespace, name, ref string var downloadLocation string @@ -636,14 +679,21 @@ func getGitSBOMPackage(repo, tag, expectedCommit string, idComponents []string, return nil, err } + // Parse cherry-picks and create ExternalRefs for them + var externalRefs []spdx.ExternalRef + if cherryPicks != "" { + externalRefs = parseCherryPicksToExternalRefs(cherryPicks) + } + return &sbom.Package{ - IDComponents: idComponents, - Name: name, - Version: v, - LicenseDeclared: licenseDeclared, - Namespace: namespace, - PURL: pu, - DownloadLocation: downloadLocation, + IDComponents: idComponents, + Name: name, + Version: v, + LicenseDeclared: licenseDeclared, + Namespace: namespace, + PURL: pu, + DownloadLocation: downloadLocation, + AdditionalExternalRefs: externalRefs, }, nil } @@ -717,6 +767,7 @@ func (p Pipeline) SBOMPackageForUpstreamSource(licenseDeclared, supplier string, tag := with["tag"] expectedCommit := with["expected-commit"] hint := with["type-hint"] + cherryPicks := with["cherry-picks"] // We'll use all available data to ensure our SBOM's package ID is unique, even // when the same repo is git-checked out multiple times. @@ -735,7 +786,7 @@ func (p Pipeline) SBOMPackageForUpstreamSource(licenseDeclared, supplier string, idComponents = append(idComponents, uniqueID) } - gitPackage, err := getGitSBOMPackage(repo, tag, expectedCommit, idComponents, licenseDeclared, hint, supplier) + gitPackage, err := getGitSBOMPackage(repo, tag, expectedCommit, cherryPicks, idComponents, licenseDeclared, hint, supplier) if err != nil { return nil, err } else if gitPackage != nil { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 018b350e9..6f4a942c8 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "chainguard.dev/apko/pkg/sbom/generator/spdx" "github.com/chainguard-dev/clog/slogtest" purl "github.com/package-url/packageurl-go" "github.com/stretchr/testify/require" @@ -875,7 +876,7 @@ func TestGetGitSBOMPackage(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - pkg, err := getGitSBOMPackage(tc.repo, tc.tag, tc.expectedCommit, tc.idComponents, tc.licenseDeclared, tc.typeHint, tc.supplier) + pkg, err := getGitSBOMPackage(tc.repo, tc.tag, tc.expectedCommit, "", tc.idComponents, tc.licenseDeclared, tc.typeHint, tc.supplier) if tc.expectError { require.Error(t, err) return @@ -902,6 +903,90 @@ func TestGetGitSBOMPackage(t *testing.T) { } } +func TestParseCherryPicksToExternalRefs(t *testing.T) { + testCases := []struct { + name string + input string + expected int // number of expected refs + checkRef func(*testing.T, []spdx.ExternalRef) + }{ + { + name: "empty string", + input: "", + expected: 0, + }, + { + name: "single cherry-pick", + input: "3.10/c62c9e518b784fe44432a3f4fc265fb95b651906: CVE-2024-4032", + expected: 1, + checkRef: func(t *testing.T, refs []spdx.ExternalRef) { + require.Equal(t, "OTHER", refs[0].Category) + require.Equal(t, "cherry-pick", refs[0].Type) + require.Equal(t, "3.10/c62c9e518b784fe44432a3f4fc265fb95b651906", refs[0].Locator) + }, + }, + { + name: "multiple cherry-picks", + input: `3.10/c62c9e518b784fe44432a3f4fc265fb95b651906: CVE-2024-4032 +main/582b4d7d62f1c512568649ce8b6db085a3d85a9f: Security fix`, + expected: 2, + checkRef: func(t *testing.T, refs []spdx.ExternalRef) { + require.Equal(t, "OTHER", refs[0].Category) + require.Equal(t, "cherry-pick", refs[0].Type) + require.Equal(t, "3.10/c62c9e518b784fe44432a3f4fc265fb95b651906", refs[0].Locator) + + require.Equal(t, "OTHER", refs[1].Category) + require.Equal(t, "cherry-pick", refs[1].Type) + require.Equal(t, "main/582b4d7d62f1c512568649ce8b6db085a3d85a9f", refs[1].Locator) + }, + }, + { + name: "with comments after hash", + input: "3.10/c62c9e518b784fe44432a3f4fc265fb95b651906: CVE-2024-4032 # this is a comment", + expected: 1, + checkRef: func(t *testing.T, refs []spdx.ExternalRef) { + require.Equal(t, "3.10/c62c9e518b784fe44432a3f4fc265fb95b651906", refs[0].Locator) + }, + }, + { + name: "with empty lines", + input: `3.10/c62c9e518b784fe44432a3f4fc265fb95b651906: CVE-2024-4032 + +main/582b4d7d62f1c512568649ce8b6db085a3d85a9f: Security fix`, + expected: 2, + }, + { + name: "commit without branch prefix", + input: "c62c9e518b784fe44432a3f4fc265fb95b651906: CVE-2024-4032", + expected: 1, + checkRef: func(t *testing.T, refs []spdx.ExternalRef) { + require.Equal(t, "c62c9e518b784fe44432a3f4fc265fb95b651906", refs[0].Locator) + }, + }, + { + name: "invalid format - missing colon", + input: "3.10/c62c9e518b784fe44432a3f4fc265fb95b651906", + expected: 0, + }, + { + name: "invalid format - missing comment", + input: "3.10/c62c9e518b784fe44432a3f4fc265fb95b651906:", + expected: 0, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + refs := parseCherryPicksToExternalRefs(tc.input) + require.Len(t, refs, tc.expected) + + if tc.checkRef != nil { + tc.checkRef(t, refs) + } + }) + } +} + func TestSetCap(t *testing.T) { tests := []struct { setcap []Capability diff --git a/pkg/sbom/package.go b/pkg/sbom/package.go index 89159d22b..90677f91e 100644 --- a/pkg/sbom/package.go +++ b/pkg/sbom/package.go @@ -86,6 +86,12 @@ type Package struct { // source locations; Leaving this empty will result in NOASSERTION being // used as its value. DownloadLocation string + + // Additional ExternalRefs beyond the PURL. This is used to store cherry-pick + // commits and other additional references that should be included in the SBOM. + // These will be appended to the ExternalRefs in the SPDX package alongside + // the PURL reference. + AdditionalExternalRefs []spdx.ExternalRef } // ToSPDX returns the Package converted to its SPDX representation. @@ -179,6 +185,9 @@ func (p Package) getExternalRefs() []spdx.ExternalRef { }) } + // Append any additional external references (e.g., cherry-picks) + result = append(result, p.AdditionalExternalRefs...) + return result }