Files
sap-jenkins-library/pkg/python/bom.go

109 lines
3.8 KiB
Go

package python
import (
"fmt"
"regexp"
"github.com/SAP/jenkins-library/pkg/log"
"github.com/SAP/jenkins-library/pkg/piperutils"
)
const (
BOMFilename = "bom-pip.xml"
)
func CreateBOM(
executeFn func(executable string, params ...string) error,
existsFn func(path string) (bool, error),
readFileFn func(path string) ([]byte, error),
virtualEnv string,
requirementsFile string,
cycloneDxVersion string,
cycloneDxSchemaVersion string,
) error {
if exists, _ := existsFn(requirementsFile); exists {
if err := InstallRequirements(executeFn, virtualEnv, requirementsFile); err != nil {
return fmt.Errorf("failed to install requirements.txt: %w", err)
}
} else {
log.Entry().Warnf("unable to find requirements.txt file at %s , continuing SBOM generation without requirements.txt", requirementsFile)
}
// Install current package to ensure it's in the venv for cyclonedx-py to detect
// For pyproject.toml builds, this is already done in BuildWithPyProjectToml, but doing it again is safe
// For setup.py builds, this ensures the package is installed
if err := InstallProjectDependencies(executeFn, virtualEnv); err != nil {
return fmt.Errorf("failed to install project dependencies: %w", err)
}
if err := InstallCycloneDX(executeFn, virtualEnv, cycloneDxVersion); err != nil {
return fmt.Errorf("failed to install cyclonedx module: %w", err)
}
log.Entry().Debug("creating BOM")
args := append([]string{"env"},
"--output-file", BOMFilename,
"--output-format", "XML",
"--spec-version", cycloneDxSchemaVersion,
)
// Add pyproject.toml only if it exists AND contains [project] metadata
// Without [project] metadata, cyclonedx-py will fail when using --pyproject flag
if hasMetadata := pyprojectHasMetadata(readFileFn, "pyproject.toml"); hasMetadata {
args = append(args, "--pyproject", "pyproject.toml")
}
if err := executeFn(getBinary(virtualEnv, "cyclonedx-py"), args...); err != nil {
return fmt.Errorf("failed to create BOM: %w", err)
}
// Post-process BOM to add purl to root component
// cyclonedx-py with --pyproject doesn't generate purl for the root component
// We use the official CycloneDX library via piperutils.UpdatePurl for robust handling
if err := addPurlToRootComponent(BOMFilename); err != nil {
log.Entry().Warnf("failed to add purl to root component: %v", err)
}
return nil
}
// addPurlToRootComponent adds a purl element to the root component in the BOM
// This is needed because cyclonedx-py doesn't generate purl when using --pyproject flag
// Uses piperutils.UpdatePurl which leverages the official CycloneDX Go library
func addPurlToRootComponent(bomFile string) error {
component := piperutils.GetComponent(bomFile)
if component.Name == "" || component.Version == "" {
return fmt.Errorf("could not extract name and version from BOM metadata")
}
// Generate PURL for PyPI package
purl := fmt.Sprintf("pkg:pypi/%s@%s", component.Name, component.Version)
log.Entry().Debugf("Adding purl to root component: %s", purl)
// Update PURL using official CycloneDX library
if err := piperutils.UpdatePurl(bomFile, purl); err != nil {
return fmt.Errorf("failed to update purl in BOM: %w", err)
}
return nil
}
// pyprojectHasMetadata checks if pyproject.toml exists and contains [project] metadata section
// Returns true only if the file exists and contains a [project] section
func pyprojectHasMetadata(readFileFn func(path string) ([]byte, error), path string) bool {
data, err := readFileFn(path)
if err != nil {
// File doesn't exist or can't be read
return false
}
content := string(data)
// Check if the file contains a [project] section
// We use a regex to match [project] at the start of a line (possibly with whitespace)
projectSectionRegex := regexp.MustCompile(`(?m)^\s*\[project\]`)
return projectSectionRegex.MatchString(content)
}