From 5230c3d45447fa91468277fb4a0d3b30aac9d39a Mon Sep 17 00:00:00 2001 From: Manjunath Date: Wed, 2 Oct 2024 09:34:34 +0200 Subject: [PATCH] Include purl info in the event (#5092) --- cmd/mavenBuild.go | 40 ++++++++- cmd/mavenBuild_test.go | 105 +++++++++++++++++++++++ pkg/npm/publish.go | 16 ++++ pkg/npm/publish_test.go | 44 ++++++++++ pkg/piperutils/cyclonedxBom.go | 48 +++++++++++ pkg/piperutils/cyclonedxbom_test.go | 126 ++++++++++++++++++++++++++++ pkg/versioning/versioning.go | 1 + 7 files changed, 378 insertions(+), 2 deletions(-) create mode 100644 pkg/piperutils/cyclonedxBom.go create mode 100644 pkg/piperutils/cyclonedxbom_test.go diff --git a/cmd/mavenBuild.go b/cmd/mavenBuild.go index af6461a8af..552d7b4e65 100644 --- a/cmd/mavenBuild.go +++ b/cmd/mavenBuild.go @@ -27,7 +27,6 @@ const ( func mavenBuild(config mavenBuildOptions, telemetryData *telemetry.CustomData, commonPipelineEnvironment *mavenBuildCommonPipelineEnvironment) { utils := maven.NewUtilsBundle() - // enables url-log.json creation cmd := reflect.ValueOf(utils).Elem().FieldByName("Command") if cmd.IsValid() { @@ -62,7 +61,7 @@ func runMavenBuild(config *mavenBuildOptions, telemetryData *telemetry.CustomDat } if config.CreateBOM { - goals = append(goals, "org.cyclonedx:cyclonedx-maven-plugin:2.7.8:makeAggregateBom") + goals = append(goals, "org.cyclonedx:cyclonedx-maven-plugin:2.7.8:makeBom", "org.cyclonedx:cyclonedx-maven-plugin:2.7.8:makeAggregateBom") createBOMConfig := []string{ "-DschemaVersion=1.4", "-DincludeBomSerialNumber=true", @@ -183,6 +182,7 @@ func runMavenBuild(config *mavenBuildOptions, telemetryData *telemetry.CustomDat } else { coordinate.BuildPath = filepath.Dir(match) coordinate.URL = config.AltDeploymentRepositoryURL + coordinate.PURL = getPurlForThePomAndDeleteIndividualBom(match) buildCoordinates = append(buildCoordinates, coordinate) } } @@ -209,6 +209,42 @@ func runMavenBuild(config *mavenBuildOptions, telemetryData *telemetry.CustomDat return err } +func getPurlForThePomAndDeleteIndividualBom(pomFilePath string) string { + bomPath := filepath.Join(filepath.Dir(pomFilePath) + "/target/" + mvnBomFilename + ".xml") + exists, _ := piperutils.FileExists(bomPath) + if !exists { + log.Entry().Debugf("bom file doesn't exist and hence no pURL info: %v", bomPath) + return "" + } + bom, err := piperutils.GetBom(bomPath) + if err != nil { + log.Entry().Warnf("failed to get bom file %s: %v", bomPath, err) + return "" + } + + log.Entry().Debugf("Found purl: %s for the bomPath: %s", bom.Metadata.Component.Purl, bomPath) + purl := bom.Metadata.Component.Purl + + // Check if the BOM is an aggregated BOM + if !isAggregatedBOM(bom) { + // Delete the individual BOM file + err = os.Remove(bomPath) + if err != nil { + log.Entry().Warnf("failed to delete bom file %s: %v", bomPath, err) + } + } + return purl +} + +func isAggregatedBOM(bom piperutils.Bom) bool { + for _, property := range bom.Metadata.Properties { + if property.Name == "maven.goal" && property.Value == "makeAggregateBom" { + return true + } + } + return false +} + func createOrUpdateProjectSettingsXML(projectSettingsFile string, altDeploymentRepositoryID string, altDeploymentRepositoryUser string, altDeploymentRepositoryPassword string, utils maven.Utils) (string, error) { if len(projectSettingsFile) > 0 { projectSettingsFilePath, err := maven.UpdateProjectSettingsXML(projectSettingsFile, altDeploymentRepositoryID, altDeploymentRepositoryUser, altDeploymentRepositoryPassword, utils) diff --git a/cmd/mavenBuild_test.go b/cmd/mavenBuild_test.go index c7f6bb3d59..ca48d89f95 100644 --- a/cmd/mavenBuild_test.go +++ b/cmd/mavenBuild_test.go @@ -4,8 +4,11 @@ package cmd import ( + "os" + "path/filepath" "testing" + "github.com/SAP/jenkins-library/pkg/piperutils" "github.com/stretchr/testify/assert" ) @@ -157,3 +160,105 @@ func TestMavenBuild(t *testing.T) { }) } + +func TestIsAggregatedBOM(t *testing.T) { + t.Run("is aggregated BOM", func(t *testing.T) { + bom := piperutils.Bom{ + Metadata: piperutils.Metadata{ + Properties: []piperutils.BomProperty{ + {Name: "maven.goal", Value: "makeAggregateBom"}, + }, + }, + } + assert.True(t, isAggregatedBOM(bom)) + }) + + t.Run("is not aggregated BOM", func(t *testing.T) { + bom := piperutils.Bom{ + Metadata: piperutils.Metadata{ + Properties: []piperutils.BomProperty{ + {Name: "some.property", Value: "someValue"}, + }, + }, + } + assert.False(t, isAggregatedBOM(bom)) + }) +} + +func createTempFile(t *testing.T, dir string, filename string, content string) string { + filePath := filepath.Join(dir, filename) + err := os.WriteFile(filePath, []byte(content), 0666) + if err != nil { + t.Fatalf("Failed to create temp file: %s", err) + } + return filePath +} + +func TestGetPurlForThePomAndDeleteIndividualBom(t *testing.T) { + t.Run("valid BOM file, non-aggregated", func(t *testing.T) { + tempDir, err := piperutils.Files{}.TempDir("", "test") + if err != nil { + t.Fatalf("Failed to create temp directory: %s", err) + } + + bomContent := ` + + + pkg:maven/com.example/mycomponent@1.0.0 + + + + + + ` + pomFilePath := createTempFile(t, tempDir, "pom.xml", "") + bomDir := filepath.Join(tempDir, "target") + if err := os.MkdirAll(bomDir, 0777); err != nil { + t.Fatalf("Failed to create temp directory: %s", err) + } + bomFilePath := createTempFile(t, bomDir, mvnBomFilename+".xml", bomContent) + defer os.Remove(bomFilePath) + + purl := getPurlForThePomAndDeleteIndividualBom(pomFilePath) + assert.Equal(t, "pkg:maven/com.example/mycomponent@1.0.0", purl) + _, err = os.Stat(bomFilePath) + assert.True(t, os.IsNotExist(err)) + }) + + t.Run("valid BOM file, aggregated BOM", func(t *testing.T) { + tempDir, err := piperutils.Files{}.TempDir("", "test") + if err != nil { + t.Fatalf("Failed to create temp directory: %s", err) + } + + bomContent := ` + + + pkg:maven/com.example/aggregatecomponent@1.0.0 + + + + + + ` + pomFilePath := createTempFile(t, tempDir, "pom.xml", "") + bomDir := filepath.Join(tempDir, "target") + if err := os.MkdirAll(bomDir, 0777); err != nil { + t.Fatalf("Failed to create temp directory: %s", err) + } + bomFilePath := createTempFile(t, bomDir, mvnBomFilename+".xml", bomContent) + + purl := getPurlForThePomAndDeleteIndividualBom(pomFilePath) + assert.Equal(t, "pkg:maven/com.example/aggregatecomponent@1.0.0", purl) + _, err = os.Stat(bomFilePath) + assert.False(t, os.IsNotExist(err)) // File should not be deleted + }) + + t.Run("BOM file does not exist", func(t *testing.T) { + tempDir := t.TempDir() + pomFilePath := createTempFile(t, tempDir, "pom.xml", "") // Create a temp pom file + + purl := getPurlForThePomAndDeleteIndividualBom(pomFilePath) + assert.Equal(t, "", purl) + }) +} diff --git a/pkg/npm/publish.go b/pkg/npm/publish.go index 660e1ae114..3466e89aea 100644 --- a/pkg/npm/publish.go +++ b/pkg/npm/publish.go @@ -217,6 +217,7 @@ func (exec *Execute) publish(packageJSON, registry, username, password string, p coordinate.BuildPath = filepath.Dir(packageJSON) coordinate.URL = registry coordinate.Packaging = "tgz" + coordinate.PURL = getPurl(packageJSON) *buildCoordinates = append(*buildCoordinates, coordinate) } @@ -225,6 +226,21 @@ func (exec *Execute) publish(packageJSON, registry, username, password string, p return nil } +func getPurl(packageJSON string) string { + expectedBomFilePath := filepath.Join(filepath.Dir(packageJSON) + "/" + npmBomFilename) + exists, _ := CredentialUtils.FileExists(expectedBomFilePath) + if !exists { + log.Entry().Debugf("bom file doesn't exist and hence no pURL info: %v", expectedBomFilePath) + return "" + } + bom, err := CredentialUtils.GetBom(expectedBomFilePath) + if err != nil { + log.Entry().Warnf("unable to get bom metdata : %v", err) + return "" + } + return bom.Metadata.Component.Purl +} + func (exec *Execute) readPackageScope(packageJSON string) (string, error) { b, err := exec.Utils.FileRead(packageJSON) if err != nil { diff --git a/pkg/npm/publish_test.go b/pkg/npm/publish_test.go index 782e1b7c5c..4c7b24f95d 100644 --- a/pkg/npm/publish_test.go +++ b/pkg/npm/publish_test.go @@ -12,6 +12,7 @@ import ( "github.com/SAP/jenkins-library/pkg/piperutils" "github.com/SAP/jenkins-library/pkg/versioning" "github.com/stretchr/testify/assert" + "os" ) type npmMockUtilsBundleRelativeGlob struct { @@ -573,3 +574,46 @@ func TestNpmPublish(t *testing.T) { }) } } + +func createTempFile(t *testing.T, dir string, filename string, content string) string { + filePath := filepath.Join(dir, filename) + err := os.WriteFile(filePath, []byte(content), 0666) + if err != nil { + t.Fatalf("Failed to create temp file: %s", err) + } + return filePath +} + +func TestGetPurl(t *testing.T) { + t.Run("valid BOM file", func(t *testing.T) { + tempDir, err := piperutils.Files{}.TempDir("", "test") + if err != nil { + t.Fatalf("Failed to create temp directory: %s", err) + } + + bomContent := ` + + + pkg:npm/com.example/mycomponent@1.0.0 + + + + + + ` + packageJsonFilePath := createTempFile(t, tempDir, "package.json", "") + bomFilePath := createTempFile(t, tempDir, npmBomFilename, bomContent) + defer os.Remove(bomFilePath) + + purl := getPurl(packageJsonFilePath) + assert.Equal(t, "pkg:npm/com.example/mycomponent@1.0.0", purl) + }) + + t.Run("BOM file does not exist", func(t *testing.T) { + tempDir := t.TempDir() + packageJsonFilePath := createTempFile(t, tempDir, "pom.xml", "") // Create a temp pom file + + purl := getPurl(packageJsonFilePath) + assert.Equal(t, "", purl) + }) +} diff --git a/pkg/piperutils/cyclonedxBom.go b/pkg/piperutils/cyclonedxBom.go new file mode 100644 index 0000000000..fddebae8a6 --- /dev/null +++ b/pkg/piperutils/cyclonedxBom.go @@ -0,0 +1,48 @@ +package piperutils + +import ( + "encoding/xml" + "github.com/SAP/jenkins-library/pkg/log" + "io" + "os" +) + +// To serialize the cyclonedx BOM file +type Bom struct { + Metadata Metadata `xml:"metadata"` +} + +type Metadata struct { + Component BomComponent `xml:"component"` + Properties []BomProperty `xml:"properties>property"` +} + +type BomProperty struct { + Name string `xml:"name,attr"` + Value string `xml:"value,attr"` +} + +type BomComponent struct { + Purl string `xml:"purl"` +} + +func GetBom(absoluteBomPath string) (Bom, error) { + xmlFile, err := os.Open(absoluteBomPath) + if err != nil { + log.Entry().Debugf("failed to open bom file %s", absoluteBomPath) + return Bom{}, err + } + defer xmlFile.Close() + byteValue, err := io.ReadAll(xmlFile) + if err != nil { + log.Entry().Debugf("failed to read bom file %s", absoluteBomPath) + return Bom{}, err + } + var bom Bom + err = xml.Unmarshal(byteValue, &bom) + if err != nil { + log.Entry().Debugf("failed to unmarshal bom file %s", absoluteBomPath) + return Bom{}, err + } + return bom, nil +} diff --git a/pkg/piperutils/cyclonedxbom_test.go b/pkg/piperutils/cyclonedxbom_test.go new file mode 100644 index 0000000000..d9d25e6256 --- /dev/null +++ b/pkg/piperutils/cyclonedxbom_test.go @@ -0,0 +1,126 @@ +package piperutils + +import ( + "os" + "path/filepath" + "testing" +) + +func createTempFile(t *testing.T, content string) (string, func()) { + dir := t.TempDir() + fileName := filepath.Join(dir, "test.xml") + err := os.WriteFile(fileName, []byte(content), 0666) + if err != nil { + t.Fatalf("Failed to create temp file: %s", err) + } + return fileName, func() { + os.Remove(fileName) + } +} + +func TestGetBom(t *testing.T) { + tests := []struct { + name string + xmlContent string + expectedBom Bom + expectError bool + expectedError string + }{ + { + name: "valid file", + xmlContent: ` + + + pkg:maven/com.example/mycomponent@1.0.0 + + + + + + + `, + expectedBom: Bom{ + Metadata: Metadata{ + Component: BomComponent{ + Purl: "pkg:maven/com.example/mycomponent@1.0.0", + }, + Properties: []BomProperty{ + {Name: "name1", Value: "value1"}, + {Name: "name2", Value: "value2"}, + }, + }, + }, + expectError: false, + }, + { + name: "file not found", + xmlContent: "", + expectedBom: Bom{}, + expectError: true, + expectedError: "no such file or directory", + }, + { + name: "invalid XML file", + xmlContent: "invalid xml", + expectedBom: Bom{}, + expectError: true, + expectedError: "XML syntax error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var fileName string + var cleanup func() + if tt.xmlContent != "" { + var err error + fileName, cleanup = createTempFile(t, tt.xmlContent) + defer cleanup() + if err != nil { + t.Fatalf("Failed to create temp file: %s", err) + } + } else { + // Use a non-existent file path + fileName = "nonexistent.xml" + } + + bom, err := GetBom(fileName) + if (err != nil) != tt.expectError { + t.Errorf("Expected error: %v, got: %v", tt.expectError, err) + } + + if err != nil && !tt.expectError { + if !tt.expectError && !containsSubstring(err.Error(), tt.expectedError) { + t.Errorf("Expected error message: %v, got: %v", tt.expectedError, err.Error()) + } + } + + if !tt.expectError && !bomEquals(bom, tt.expectedBom) { + t.Errorf("Expected BOM: %+v, got: %+v", tt.expectedBom, bom) + } + }) + } +} + +func bomEquals(a, b Bom) bool { + // compare a and b manually since reflect.DeepEqual can be problematic with slices and nil values + return a.Metadata.Component.Purl == b.Metadata.Component.Purl && + len(a.Metadata.Properties) == len(b.Metadata.Properties) && + propertiesMatch(a.Metadata.Properties, b.Metadata.Properties) +} + +func propertiesMatch(a, b []BomProperty) bool { + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func containsSubstring(str, substr string) bool { + if len(substr) == 0 { + return true + } + return len(str) >= len(substr) && str[:len(substr)] == substr +} diff --git a/pkg/versioning/versioning.go b/pkg/versioning/versioning.go index 1ea42b48f8..a716cb192f 100644 --- a/pkg/versioning/versioning.go +++ b/pkg/versioning/versioning.go @@ -19,6 +19,7 @@ type Coordinates struct { Packaging string BuildPath string URL string + PURL string } // Artifact defines the versioning operations for various build tools