diff --git a/src/pkg/bundle/inspect.go b/src/pkg/bundle/inspect.go index 58a65773..2c78423c 100644 --- a/src/pkg/bundle/inspect.go +++ b/src/pkg/bundle/inspect.go @@ -33,6 +33,7 @@ import ( func (b *Bundle) Inspect() error { // print to stdout to enable users to easily grab the output pterm.SetDefaultOutput(os.Stdout) + var warns []string if err := utils.CheckYAMLSourcePath(b.cfg.InspectOpts.Source); err == nil { b.cfg.InspectOpts.IsYAMLFile = true @@ -71,7 +72,7 @@ func (b *Bundle) Inspect() error { // pull sbom if b.cfg.InspectOpts.IncludeSBOM { - err := provider.CreateBundleSBOM(b.cfg.InspectOpts.ExtractSBOM, b.bundle.Metadata.Name) + warns, err = provider.CreateBundleSBOM(b.cfg.InspectOpts.ExtractSBOM, b.bundle.Metadata.Name) if err != nil { return err } @@ -97,6 +98,13 @@ func (b *Bundle) Inspect() error { } zarfUtils.ColorPrintYAML(b.bundle, nil, false) + + // print warnings to stderr + pterm.SetDefaultOutput(os.Stderr) + for _, warn := range warns { + message.Warnf(warn) + } + return nil } diff --git a/src/pkg/bundle/provider.go b/src/pkg/bundle/provider.go index 397588e6..8f06612f 100644 --- a/src/pkg/bundle/provider.go +++ b/src/pkg/bundle/provider.go @@ -38,7 +38,7 @@ type Provider interface { LoadBundle(options types.BundlePullOptions, concurrency int) (*types.UDSBundle, types.PathMap, error) // CreateBundleSBOM creates a bundle-level SBOM from the underlying Zarf packages, if the Zarf package contains an SBOM - CreateBundleSBOM(extractSBOM bool, bundleName string) error + CreateBundleSBOM(extractSBOM bool, bundleName string) ([]string, error) // PublishBundle publishes a bundle to a remote OCI repo PublishBundle(bundle types.UDSBundle, remote *oci.OrasRemote) error diff --git a/src/pkg/bundle/remote.go b/src/pkg/bundle/remote.go index 2aeef0dd..b02c769b 100644 --- a/src/pkg/bundle/remote.go +++ b/src/pkg/bundle/remote.go @@ -85,20 +85,20 @@ func (op *ociProvider) LoadBundleMetadata() (types.PathMap, error) { } // CreateBundleSBOM creates a bundle-level SBOM from the underlying Zarf packages, if the Zarf package contains an SBOM -func (op *ociProvider) CreateBundleSBOM(extractSBOM bool, bundleName string) error { +func (op *ociProvider) CreateBundleSBOM(extractSBOM bool, bundleName string) ([]string, error) { + var warns []string ctx := context.TODO() SBOMArtifactPathMap := make(types.PathMap) root, err := op.FetchRoot(ctx) if err != nil { - return err + return warns, err } // make tmp dir for pkg SBOM extraction err = os.Mkdir(filepath.Join(op.dst, config.BundleSBOM), 0700) if err != nil { - return err + return warns, err } - containsSBOMs := false // iterate through Zarf image manifests and find the Zarf pkg's sboms.tar for _, layer := range root.Layers { @@ -107,7 +107,7 @@ func (op *ociProvider) CreateBundleSBOM(extractSBOM bool, bundleName string) err } zarfManifest, err := op.OrasRemote.FetchManifest(ctx, layer) if err != nil { - return err + return warns, err } // grab descriptor for sboms.tar sbomDesc := zarfManifest.Locate(config.SBOMsTar) @@ -118,35 +118,17 @@ func (op *ociProvider) CreateBundleSBOM(extractSBOM bool, bundleName string) err // grab sboms.tar and extract sbomBytes, err := op.OrasRemote.FetchLayer(ctx, sbomDesc) if err != nil { - return err + return warns, err } + extractor := utils.SBOMExtractor(op.dst, SBOMArtifactPathMap) err = archiver.Tar{}.Extract(context.TODO(), bytes.NewReader(sbomBytes), nil, extractor) if err != nil { - return err + return warns, err } - containsSBOMs = true } - if extractSBOM { - if !containsSBOMs { - message.Warnf("Cannot extract, no SBOMs found in bundle") - return nil - } - currentDir, err := os.Getwd() - if err != nil { - return err - } - err = utils.MoveExtractedSBOMs(bundleName, op.dst, currentDir) - if err != nil { - return err - } - } else { - err = utils.CreateSBOMArtifact(SBOMArtifactPathMap, bundleName) - if err != nil { - return err - } - } - return nil + + return utils.HandleSBOM(extractSBOM, SBOMArtifactPathMap, bundleName, op.dst) } // LoadBundle loads a bundle from a remote source diff --git a/src/pkg/bundle/tarball.go b/src/pkg/bundle/tarball.go index 2f523083..15e511ef 100644 --- a/src/pkg/bundle/tarball.go +++ b/src/pkg/bundle/tarball.go @@ -38,18 +38,20 @@ type tarballBundleProvider struct { } // CreateBundleSBOM creates a bundle-level SBOM from the underlying Zarf packages, if the Zarf package contains an SBOM -func (tp *tarballBundleProvider) CreateBundleSBOM(extractSBOM bool, bundleName string) error { +func (tp *tarballBundleProvider) CreateBundleSBOM(extractSBOM bool, bundleName string) ([]string, error) { + var warns []string rootManifest, err := tp.getBundleManifest() if err != nil { - return err + return warns, err } // make tmp dir for pkg SBOM extraction err = os.Mkdir(filepath.Join(tp.dst, config.BundleSBOM), 0o700) if err != nil { - return err + return warns, err } + + // track SBOM artifact paths, used for extraction and creation of bundleSBOM artifact SBOMArtifactPathMap := make(types.PathMap) - containsSBOMs := false for _, layer := range rootManifest.Layers { // get Zarf image manifests from bundle manifest @@ -58,17 +60,17 @@ func (tp *tarballBundleProvider) CreateBundleSBOM(extractSBOM bool, bundleName s } layerFilePath := filepath.Join(config.BlobsDir, layer.Digest.Encoded()) if err := av3.Extract(tp.src, layerFilePath, tp.dst); err != nil { - return fmt.Errorf("failed to extract %s from %s: %w", layer.Digest.Encoded(), tp.src, err) + return warns, fmt.Errorf("failed to extract %s from %s: %w", layer.Digest.Encoded(), tp.src, err) } // read in and unmarshal Zarf image manifest zarfManifestBytes, err := os.ReadFile(filepath.Join(tp.dst, layerFilePath)) if err != nil { - return err + return warns, err } var zarfImageManifest *oci.Manifest if err := json.Unmarshal(zarfManifestBytes, &zarfImageManifest); err != nil { - return err + return warns, err } // find sbom layer descriptor and extract sbom tar from archive @@ -76,45 +78,37 @@ func (tp *tarballBundleProvider) CreateBundleSBOM(extractSBOM bool, bundleName s // if sbomDesc doesn't exist, continue if oci.IsEmptyDescriptor(sbomDesc) { - message.Warnf("%s not found in Zarf pkg", config.SBOMsTar) continue } sbomFilePath := filepath.Join(config.BlobsDir, sbomDesc.Digest.Encoded()) + + // check if file path already exists and remove + // this fixes a bug where multiple pkgs have an empty SBOM tar archive + if _, err := os.Stat(filepath.Join(tp.dst, sbomFilePath)); err == nil { + err = os.Remove(filepath.Join(tp.dst, sbomFilePath)) + if err != nil { + return warns, err + } + } + if err := av3.Extract(tp.src, sbomFilePath, tp.dst); err != nil { - return fmt.Errorf("failed to extract %s from %s: %w", layer.Digest.Encoded(), tp.src, err) + return warns, fmt.Errorf("failed to extract %s from %s: %w", layer.Digest.Encoded(), tp.src, err) } sbomTarBytes, err := os.ReadFile(filepath.Join(tp.dst, sbomFilePath)) if err != nil { - return err + return warns, err } extractor := utils.SBOMExtractor(tp.dst, SBOMArtifactPathMap) + + // extract SBOMs from tar err = av4.Tar{}.Extract(context.TODO(), bytes.NewReader(sbomTarBytes), nil, extractor) if err != nil { - return err + return warns, err } - containsSBOMs = true } - if extractSBOM { - if !containsSBOMs { - message.Warnf("Cannot extract, no SBOMs found in bundle") - return nil - } - currentDir, err := os.Getwd() - if err != nil { - return err - } - err = utils.MoveExtractedSBOMs(bundleName, tp.dst, currentDir) - if err != nil { - return err - } - } else { - err = utils.CreateSBOMArtifact(SBOMArtifactPathMap, bundleName) - if err != nil { - return err - } - } - return nil + + return utils.HandleSBOM(extractSBOM, SBOMArtifactPathMap, bundleName, tp.dst) } func (tp *tarballBundleProvider) getBundleManifest() (*oci.Manifest, error) { diff --git a/src/pkg/utils/sbom.go b/src/pkg/utils/sbom.go index 97152377..5528c6a3 100644 --- a/src/pkg/utils/sbom.go +++ b/src/pkg/utils/sbom.go @@ -14,8 +14,8 @@ import ( "github.com/mholt/archiver/v4" ) -// CreateSBOMArtifact creates sbom artifacts in the form of a tar archive -func CreateSBOMArtifact(SBOMArtifactPathMap map[string]string, bundleName string) error { +// createSBOMArtifact creates sbom artifacts in the form of a tar archive +func createSBOMArtifact(SBOMArtifactPathMap map[string]string, bundleName string) error { out, err := os.Create(fmt.Sprintf("%s-%s", bundleName, config.BundleSBOMTar)) if err != nil { return err @@ -33,8 +33,8 @@ func CreateSBOMArtifact(SBOMArtifactPathMap map[string]string, bundleName string return nil } -// MoveExtractedSBOMs moves the extracted SBOM HTML and JSON files from src to dst -func MoveExtractedSBOMs(bundleName, src, dst string) error { +// moveExtractedSBOMs moves the extracted SBOM HTML and JSON files from src to dst +func moveExtractedSBOMs(bundleName, src, dst string) error { srcSBOMPath := filepath.Join(src, config.BundleSBOM) extractDirName := fmt.Sprintf("%s-%s", bundleName, config.BundleSBOM) sbomDir := filepath.Join(dst, extractDirName) @@ -86,3 +86,32 @@ func SBOMExtractor(dst string, SBOMArtifactPathMap map[string]string) func(_ con } return extractor } + +// HandleSBOM handles the extraction and creation of bundle SBOMs after populating SBOMArtifactPathMap +func HandleSBOM(extractSBOM bool, SBOMArtifactPathMap map[string]string, bundleName, dstPath string) ([]string, error) { + var warns []string + + if extractSBOM { + if len(SBOMArtifactPathMap) == 0 { + warns = append(warns, "Cannot extract, no SBOMs found in bundle") + return warns, nil + } + currentDir, err := os.Getwd() + if err != nil { + return warns, err + } + err = moveExtractedSBOMs(bundleName, dstPath, currentDir) + if err != nil { + return warns, err + } + } else if len(SBOMArtifactPathMap) > 0 { + err := createSBOMArtifact(SBOMArtifactPathMap, bundleName) + if err != nil { + return warns, err + } + } else { + warns = append(warns, "No SBOMs found in bundle") + } + + return warns, nil +} diff --git a/src/test/bundles/11-real-simple/multiple-simple/uds-bundle.yaml b/src/test/bundles/11-real-simple/multiple-simple/uds-bundle.yaml new file mode 100644 index 00000000..10f6cd6b --- /dev/null +++ b/src/test/bundles/11-real-simple/multiple-simple/uds-bundle.yaml @@ -0,0 +1,15 @@ +kind: UDSBundle +metadata: + name: multiple-simple + description: | + bundles multiple simple pkgs to test SBOM generation during inspect, + note that no sboms are in the bundled pkgs + version: 0.0.1 + +packages: + - name: real-simple + path: "../../../packages/no-cluster/real-simple" + ref: 0.0.1 + - name: output-var + path: "../../../packages/no-cluster/output-var" + ref: 0.0.1 diff --git a/src/test/e2e/bundle_test.go b/src/test/e2e/bundle_test.go index 493d4cbc..4e41e4bf 100644 --- a/src/test/e2e/bundle_test.go +++ b/src/test/e2e/bundle_test.go @@ -241,6 +241,37 @@ func TestLocalBundleWithOutput(t *testing.T) { runCmd(t, fmt.Sprintf("inspect %s", bundlePath)) } +func TestSimplePackagesWithSBOMs(t *testing.T) { + // tests that this bug is resolved: https://github.com/defenseunicorns/uds-cli/issues/923 + e2e.CreateZarfPkg(t, "src/test/packages/no-cluster/output-var", false) + e2e.CreateZarfPkg(t, "src/test/packages/no-cluster/real-simple", false) + + bundleDir := "src/test/bundles/11-real-simple/multiple-simple" + bundlePath := filepath.Join(bundleDir, fmt.Sprintf("uds-bundle-multiple-simple-%s-0.0.1.tar.zst", e2e.Arch)) + runCmd(t, fmt.Sprintf("create %s --confirm -a %s", bundleDir, e2e.Arch)) + + t.Run("test local bundle with simple packages and no SBOMs", func(t *testing.T) { + _, stderr := runCmd(t, fmt.Sprintf("inspect %s --sbom", bundlePath)) + require.Contains(t, stderr, "No SBOMs found in bundle") + _, stderr = runCmd(t, fmt.Sprintf("inspect %s --sbom --extract", bundlePath)) + require.Contains(t, stderr, "Cannot extract, no SBOMs found in bundle") + }) + + t.Run("test remote bundle with simple packages and no SBOMs", func(t *testing.T) { + // publish bundle to registry + e2e.SetupDockerRegistry(t, 888) + defer e2e.TeardownRegistry(t, 888) + runCmd(t, fmt.Sprintf("publish %s %s --insecure", bundlePath, "localhost:888")) + + // inspect bundle for sboms + remoteBundlePath := "localhost:888/multiple-simple:0.0.1" + _, stderr := runCmd(t, fmt.Sprintf("inspect %s --insecure --sbom", remoteBundlePath)) + require.Contains(t, stderr, "No SBOMs found in bundle") + _, stderr = runCmd(t, fmt.Sprintf("inspect %s --insecure --sbom --extract", remoteBundlePath)) + require.Contains(t, stderr, "Cannot extract, no SBOMs found in bundle") + }) +} + func TestLocalBundleWithNoSBOM(t *testing.T) { path := "src/test/packages/nginx" runCmd(t, fmt.Sprintf("zarf package create %s -o %s --skip-sbom --confirm", path, path)) @@ -249,9 +280,8 @@ func TestLocalBundleWithNoSBOM(t *testing.T) { bundlePath := filepath.Join(bundleDir, fmt.Sprintf("uds-bundle-yml-example-%s-0.0.1.tar.zst", e2e.Arch)) runCmd(t, fmt.Sprintf("create %s --insecure --confirm -a %s", bundleDir, e2e.Arch)) - stdout, _ := runCmd(t, fmt.Sprintf("inspect %s --sbom --extract", bundlePath)) - require.Contains(t, stdout, "Cannot extract, no SBOMs found in bundle") - require.Contains(t, stdout, "sboms.tar not found in Zarf pkg") + _, stderr := runCmd(t, fmt.Sprintf("inspect %s --sbom --extract", bundlePath)) + require.Contains(t, stderr, "Cannot extract, no SBOMs found in bundle") } func TestRemoteBundleWithNoSBOM(t *testing.T) { @@ -267,9 +297,8 @@ func TestRemoteBundleWithNoSBOM(t *testing.T) { runCmd(t, fmt.Sprintf("create %s --insecure --confirm -a %s", bundleDir, e2e.Arch)) runCmd(t, fmt.Sprintf("publish %s %s --insecure", bundlePath, "localhost:888")) - stdout, _ := runCmd(t, fmt.Sprintf("inspect %s --sbom --extract", bundlePath)) - require.Contains(t, stdout, "Cannot extract, no SBOMs found in bundle") - require.Contains(t, stdout, "sboms.tar not found in Zarf pkg") + _, stderr := runCmd(t, fmt.Sprintf("inspect %s --sbom --extract", bundlePath)) + require.Contains(t, stderr, "Cannot extract, no SBOMs found in bundle") } func TestPackageNaming(t *testing.T) {