Repository: goreleaser/goreleaser
Pull request #2648 (2021-11-08 18:11:44)

feat: add sbom generation
This PR adds a new `sboms` section to generate SBOMs for one or more generated artifacts. Example usage:
```
sboms:
  -
    id: binary-sbom
    artifacts: binary

```
See doc updates in this PR for more detailed configuration explanation. With this approach SBOMs generated can be checksummed and referenced under the `signs` section.

_(This is a follow up from https://github.com/goreleaser/goreleaser/issues/2597#issuecomment-957842629 .)_


codecov[bot]: # [Codecov](https://codecov.io/gh/goreleaser/goreleaser/pull/2648?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) Report
> Merging [#2648](https://codecov.io/gh/goreleaser/goreleaser/pull/2648?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) (479625b) into [main](https://codecov.io/gh/goreleaser/goreleaser/commit/47d0e827436f3c2ed8caac9d7f508b2a862c0e00?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) (47d0e82) will **decrease** coverage by `1.62%`.
> The diff coverage is `6.50%`.

[![Impacted file tree graph](https://codecov.io/gh/goreleaser/goreleaser/pull/2648/graphs/tree.svg?width=650&height=150&src=pr&token=3bJvv6b7hN&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser)](https://codecov.io/gh/goreleaser/goreleaser/pull/2648?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser)

```diff
@@            Coverage Diff             @@
##             main    #2648      +/-   ##
==========================================
- Coverage   84.64%   83.01%   -1.63%     
==========================================
  Files         100      103       +3     
  Lines        7338     7944     +606     
==========================================
+ Hits         6211     6595     +384     
- Misses        931     1134     +203     
- Partials      196      215      +19     
```


| [Impacted Files](https://codecov.io/gh/goreleaser/goreleaser/pull/2648?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) | Coverage Δ | |
|---|---|---|
| [internal/artifact/artifact.go](https://codecov.io/gh/goreleaser/goreleaser/pull/2648/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-aW50ZXJuYWwvYXJ0aWZhY3QvYXJ0aWZhY3QuZ28=) | `98.33% <ø> (+0.03%)` | :arrow_up: |
| [internal/pipe/pipe.go](https://codecov.io/gh/goreleaser/goreleaser/pull/2648/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-aW50ZXJuYWwvcGlwZS9waXBlLmdv) | `100.00% <ø> (ø)` | |
| [pkg/config/config.go](https://codecov.io/gh/goreleaser/goreleaser/pull/2648/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-cGtnL2NvbmZpZy9jb25maWcuZ28=) | `87.15% <ø> (ø)` | |
| [pkg/context/context.go](https://codecov.io/gh/goreleaser/goreleaser/pull/2648/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-cGtnL2NvbnRleHQvY29udGV4dC5nbw==) | `100.00% <ø> (ø)` | |
| [internal/pipe/sbom/sbom.go](https://codecov.io/gh/goreleaser/goreleaser/pull/2648/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-aW50ZXJuYWwvcGlwZS9zYm9tL3Nib20uZ28=) | `3.70% <3.70%> (ø)` | |
| [internal/pipe/sign/sign.go](https://codecov.io/gh/goreleaser/goreleaser/pull/2648/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-aW50ZXJuYWwvcGlwZS9zaWduL3NpZ24uZ28=) | `95.74% <33.33%> (+3.90%)` | :arrow_up: |
| [cmd/release.go](https://codecov.io/gh/goreleaser/goreleaser/pull/2648/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-Y21kL3JlbGVhc2UuZ28=) | `100.00% <100.00%> (ø)` | |
| [internal/pipe/checksums/checksums.go](https://codecov.io/gh/goreleaser/goreleaser/pull/2648/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-aW50ZXJuYWwvcGlwZS9jaGVja3N1bXMvY2hlY2tzdW1zLmdv) | `100.00% <100.00%> (ø)` | |
| [internal/tmpl/tmpl.go](https://codecov.io/gh/goreleaser/goreleaser/pull/2648/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-aW50ZXJuYWwvdG1wbC90bXBsLmdv) | `97.56% <100.00%> (+0.04%)` | :arrow_up: |
| [internal/client/client.go](https://codecov.io/gh/goreleaser/goreleaser/pull/2648/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-aW50ZXJuYWwvY2xpZW50L2NsaWVudC5nbw==) | `86.48% <0.00%> (-7.27%)` | :arrow_down: |
| ... and [24 more](https://codecov.io/gh/goreleaser/goreleaser/pull/2648/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/goreleaser/goreleaser/pull/2648?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/goreleaser/goreleaser/pull/2648?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser). Last update [47d0e82...479625b](https://codecov.io/gh/goreleaser/goreleaser/pull/2648?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser).

wagoodman: Righto @caarlos0 , thanks for the extra review (the comments are helpful ❤️ ). I'll adjust the current branch and start adding docs / tests (it seems like the direction has been settled)... when I'm done I'll take it out of draft (🎉 ).
caarlos0: amazing, let me know if you need any help in the meantime (I'm on our dicord and also gophers slack as well)
wagoodman: @caarlos0 I think this one is ready for review 🎉 , when CI gets kicked off I'll come back and see what the results were 🤞 
wagoodman: @caarlos0 thanks! I added the new `sboms` section to the local `.goreleaser.yaml` and also added a new `signs` config. You certainly won't have a shortage of files on releases!
wagoodman: I'm going to add a few more tests under `sbom_test.go` to get coverage up and I realized I'm missing tests for `sbom` artifacts under `signs_test.go`, so I'll add those too.

Patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 6e4025b3..02320595 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -81,6 +81,10 @@ jobs:
             ./dist/*.apk
           key: ${{ runner.os }}-go-${{ hashFiles('**/*.go') }}-${{ hashFiles('**/go.sum') }}
       - uses: sigstore/cosign-installer@v1.4.1
+      - name: setup-syft
+        run: |
+          curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | \
+          sh -s -- -b /usr/local/bin v0.32.0
       - name: setup-validate-krew-manifest
         # TODO: replace this once https://github.com/kubernetes-sigs/krew/pull/736 is merged
         run: go install github.com/caarlos0/krew/cmd/validate-krew-manifest@fork
diff --git a/.gitignore b/.gitignore
index de753ddc..0ab381ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,4 @@ www/docs/static/releases*.json
 completions/
 .vscode/
 .task/
+.idea/
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index ea62be7d..65f355ed 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -201,11 +201,19 @@ snapcrafts:
     confinement: classic
     publish: true
 
+sboms:
+  - artifacts: archive
+
 signs:
 - cmd: cosign
   stdin: '{{ .Env.COSIGN_PWD }}'
   args: ["sign-blob", "-key=cosign.key", "-output=${signature}", "${artifact}"]
   artifacts: checksum
+- id: sign-sboms
+  cmd: cosign
+  stdin: '{{ .Env.COSIGN_PWD }}'
+  args: ["sign-blob", "-key=cosign.key", "-output=${signature}", "${artifact}"]
+  artifacts: sbom
 
 docker_signs:
 - artifacts: manifests
diff --git a/cmd/release.go b/cmd/release.go
index 1014cf76..292ed753 100644
--- a/cmd/release.go
+++ b/cmd/release.go
@@ -22,23 +22,24 @@ type releaseCmd struct {
 }
 
 type releaseOpts struct {
-	config            string
-	releaseNotesFile  string
-	releaseNotesTmpl  string
-	releaseHeaderFile string
-	releaseHeaderTmpl string
-	releaseFooterFile string
-	releaseFooterTmpl string
-	autoSnapshot      bool
-	snapshot          bool
-	skipPublish       bool
-	skipSign          bool
-	skipValidate      bool
-	skipAnnounce      bool
-	rmDist            bool
-	deprecated        bool
-	parallelism       int
-	timeout           time.Duration
+	config             string
+	releaseNotesFile   string
+	releaseNotesTmpl   string
+	releaseHeaderFile  string
+	releaseHeaderTmpl  string
+	releaseFooterFile  string
+	releaseFooterTmpl  string
+	autoSnapshot       bool
+	snapshot           bool
+	skipPublish        bool
+	skipSign           bool
+	skipValidate       bool
+	skipAnnounce       bool
+	skipSBOMCataloging bool
+	rmDist             bool
+	deprecated         bool
+	parallelism        int
+	timeout            time.Duration
 }
 
 func newReleaseCmd() *releaseCmd {
@@ -82,6 +83,7 @@ func newReleaseCmd() *releaseCmd {
 	cmd.Flags().BoolVar(&root.opts.skipPublish, "skip-publish", false, "Skips publishing artifacts")
 	cmd.Flags().BoolVar(&root.opts.skipAnnounce, "skip-announce", false, "Skips announcing releases (implies --skip-validate)")
 	cmd.Flags().BoolVar(&root.opts.skipSign, "skip-sign", false, "Skips signing artifacts")
+	cmd.Flags().BoolVar(&root.opts.skipSBOMCataloging, "skip-sbom", false, "Skips cataloging artifacts")
 	cmd.Flags().BoolVar(&root.opts.skipValidate, "skip-validate", false, "Skips git checks")
 	cmd.Flags().BoolVar(&root.opts.rmDist, "rm-dist", false, "Removes the dist folder")
 	cmd.Flags().IntVarP(&root.opts.parallelism, "parallelism", "p", 0, "Amount tasks to run concurrently (default: number of CPUs)")
@@ -139,6 +141,7 @@ func setupReleaseContext(ctx *context.Context, options releaseOpts) *context.Con
 	ctx.SkipAnnounce = ctx.Snapshot || options.skipPublish || options.skipAnnounce
 	ctx.SkipValidate = ctx.Snapshot || options.skipValidate
 	ctx.SkipSign = options.skipSign
+	ctx.SkipSBOMCataloging = options.skipSBOMCataloging
 	ctx.RmDist = options.rmDist
 
 	// test only
diff --git a/internal/artifact/artifact.go b/internal/artifact/artifact.go
index 19784e0c..dea42482 100644
--- a/internal/artifact/artifact.go
+++ b/internal/artifact/artifact.go
@@ -61,6 +61,8 @@ const (
 	KrewPluginManifest
 	// ScoopManifest is an uploadable scoop manifest file.
 	ScoopManifest
+	// SBOM is a Software Bill of Materials file.
+	SBOM
 )
 
 func (t Type) String() string {
@@ -95,6 +97,8 @@ func (t Type) String() string {
 		return "Krew Plugin Manifest"
 	case ScoopManifest:
 		return "Scoop Manifest"
+	case SBOM:
+		return "SBOM"
 	default:
 		return "unknown"
 	}
diff --git a/internal/pipe/checksums/checksums.go b/internal/pipe/checksums/checksums.go
index adeb11ba..1d1dc7cb 100644
--- a/internal/pipe/checksums/checksums.go
+++ b/internal/pipe/checksums/checksums.go
@@ -70,6 +70,7 @@ func refresh(ctx *context.Context, filepath string) error {
 		artifact.ByType(artifact.UploadableBinary),
 		artifact.ByType(artifact.UploadableSourceArchive),
 		artifact.ByType(artifact.LinuxPackage),
+		artifact.ByType(artifact.SBOM),
 	)
 	if len(ctx.Config.Checksum.IDs) > 0 {
 		filter = artifact.And(filter, artifact.ByIDs(ctx.Config.Checksum.IDs...))
diff --git a/internal/pipe/sbom/sbom.go b/internal/pipe/sbom/sbom.go
new file mode 100644
index 00000000..d1b253cb
--- /dev/null
+++ b/internal/pipe/sbom/sbom.go
@@ -0,0 +1,282 @@
+package sbom
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+
+	"github.com/apex/log"
+	"github.com/goreleaser/goreleaser/internal/artifact"
+	"github.com/goreleaser/goreleaser/internal/gio"
+	"github.com/goreleaser/goreleaser/internal/ids"
+	"github.com/goreleaser/goreleaser/internal/logext"
+	"github.com/goreleaser/goreleaser/internal/semerrgroup"
+	"github.com/goreleaser/goreleaser/internal/tmpl"
+	"github.com/goreleaser/goreleaser/pkg/config"
+	"github.com/goreleaser/goreleaser/pkg/context"
+)
+
+// Environment variables to pass through to exec
+var passthroughEnvVars = []string{"HOME", "USER", "USERPROFILE", "TMPDIR", "TMP", "TEMP", "PATH"}
+
+// Pipe that catalogs common artifacts as an SBOM.
+type Pipe struct{}
+
+func (Pipe) String() string { return "cataloging artifacts" }
+func (Pipe) Skip(ctx *context.Context) bool {
+	return ctx.SkipSBOMCataloging || len(ctx.Config.SBOMs) == 0
+}
+
+// Default sets the Pipes defaults.
+func (Pipe) Default(ctx *context.Context) error {
+	ids := ids.New("sboms")
+	for i := range ctx.Config.SBOMs {
+		cfg := &ctx.Config.SBOMs[i]
+		if cfg.Cmd == "" {
+			cfg.Cmd = "syft"
+		}
+		if cfg.Artifacts == "" {
+			cfg.Artifacts = "archive"
+		}
+		if len(cfg.Documents) == 0 {
+			switch cfg.Artifacts {
+			case "binary":
+				cfg.Documents = []string{"{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.sbom"}
+			case "any":
+				cfg.Documents = []string{}
+			default:
+				cfg.Documents = []string{"{{ .ArtifactName }}.sbom"}
+			}
+		}
+		if cfg.Cmd == "syft" {
+			if len(cfg.Args) == 0 {
+				cfg.Args = []string{"$artifact", "--file", "$document", "--output", "spdx-json"}
+			}
+			if len(cfg.Env) == 0 && cfg.Artifacts == "source" || cfg.Artifacts == "archive" {
+				cfg.Env = []string{
+					"SYFT_FILE_METADATA_CATALOGER_ENABLED=true",
+				}
+			}
+		}
+		if cfg.ID == "" {
+			cfg.ID = "default"
+		}
+
+		if cfg.Artifacts != "any" && len(cfg.Documents) > 1 {
+			return fmt.Errorf("multiple SBOM outputs when artifacts=%q is unsupported", cfg.Artifacts)
+		}
+
+		ids.Inc(cfg.ID)
+	}
+	return ids.Validate()
+}
+
+// Run executes the Pipe.
+func (Pipe) Run(ctx *context.Context) error {
+	g := semerrgroup.New(ctx.Parallelism)
+	for _, cfg := range ctx.Config.SBOMs {
+		g.Go(catalogTask(ctx, cfg))
+	}
+	return g.Wait()
+}
+
+func catalogTask(ctx *context.Context, cfg config.SBOM) func() error {
+	return func() error {
+		var filters []artifact.Filter
+		switch cfg.Artifacts {
+		case "source":
+			filters = append(filters, artifact.ByType(artifact.UploadableSourceArchive))
+			if len(cfg.IDs) > 0 {
+				log.Warn("when artifacts is `source`, `ids` has no effect. ignoring")
+			}
+		case "archive":
+			filters = append(filters, artifact.ByType(artifact.UploadableArchive))
+		case "binary":
+			filters = append(filters, artifact.Or(
+				artifact.ByType(artifact.Binary),
+				artifact.ByType(artifact.UploadableBinary),
+				artifact.ByType(artifact.UniversalBinary),
+			))
+		case "package":
+			filters = append(filters, artifact.ByType(artifact.LinuxPackage))
+		case "any":
+			newArtifacts, err := catalogArtifact(ctx, cfg, nil)
+			if err != nil {
+				return err
+			}
+			for _, newArtifact := range newArtifacts {
+				ctx.Artifacts.Add(newArtifact)
+			}
+			return nil
+		default:
+			return fmt.Errorf("invalid list of artifacts to catalog: %s", cfg.Artifacts)
+		}
+
+		if len(cfg.IDs) > 0 {
+			filters = append(filters, artifact.ByIDs(cfg.IDs...))
+		}
+		artifacts := ctx.Artifacts.Filter(artifact.And(filters...)).List()
+		return catalog(ctx, cfg, artifacts)
+	}
+}
+
+func catalog(ctx *context.Context, cfg config.SBOM, artifacts []*artifact.Artifact) error {
+	for _, a := range artifacts {
+		newArtifacts, err := catalogArtifact(ctx, cfg, a)
+		if err != nil {
+			return err
+		}
+		for _, newArtifact := range newArtifacts {
+			ctx.Artifacts.Add(newArtifact)
+		}
+	}
+	return nil
+}
+
+func subprocessDistPath(distDir string, pathRelativeToCwd string) (string, error) {
+	cwd, err := os.Getwd()
+	if err != nil {
+		return "", err
+	}
+	if !filepath.IsAbs(distDir) {
+		distDir, err = filepath.Abs(distDir)
+		if err != nil {
+			return "", err
+		}
+	}
+	relativePath, err := filepath.Rel(cwd, distDir)
+	if err != nil {
+		return "", err
+	}
+	return strings.TrimPrefix(pathRelativeToCwd, relativePath+string(filepath.Separator)), nil
+}
+
+func catalogArtifact(ctx *context.Context, cfg config.SBOM, a *artifact.Artifact) ([]*artifact.Artifact, error) {
+	env := ctx.Env.Copy()
+	artifactDisplayName := "(any)"
+	templater := tmpl.New(ctx).WithEnv(env)
+
+	if a != nil {
+		procPath, err := subprocessDistPath(ctx.Config.Dist, a.Path)
+		if err != nil {
+			return nil, fmt.Errorf("cataloging artifacts failed: cannot determine artifact path for %q: %w", a.Path, err)
+		}
+		env["artifact"] = procPath
+		env["artifactID"] = a.ID()
+
+		templater = templater.WithArtifact(a, nil)
+		artifactDisplayName = a.Path
+	}
+
+	var paths []string
+	for idx, sbom := range cfg.Documents {
+		input := filepath.Join(ctx.Config.Dist, expand(sbom, env))
+
+		path, err := templater.Apply(input)
+		if err != nil {
+			return nil, fmt.Errorf("cataloging artifacts failed: %s: invalid template: %w", input, err)
+		}
+
+		path, err = filepath.Abs(path)
+		if err != nil {
+			return nil, fmt.Errorf("cataloging artifacts failed: unable to create artifact path %q: %w", sbom, err)
+		}
+
+		procPath, err := subprocessDistPath(ctx.Config.Dist, path)
+		if err != nil {
+			return nil, fmt.Errorf("cataloging artifacts failed: cannot determine document path for %q: %w", path, err)
+		}
+
+		env[fmt.Sprintf("document%d", idx)] = procPath
+		if idx == 0 {
+			env["document"] = procPath
+		}
+
+		paths = append(paths, procPath)
+	}
+
+	var names []string
+	for _, p := range paths {
+		names = append(names, filepath.Base(p))
+	}
+
+	fields := log.Fields{"cmd": cfg.Cmd, "artifact": artifactDisplayName, "sboms": strings.Join(names, ", ")}
+
+	// nolint:prealloc
+	var args []string
+	for _, arg := range cfg.Args {
+		renderedArg, err := templater.Apply(expand(arg, env))
+		if err != nil {
+			return nil, fmt.Errorf("cataloging artifacts failed: %s: invalid template: %w", arg, err)
+		}
+		args = append(args, renderedArg)
+	}
+
+	// The GoASTScanner flags this as a security risk.
+	// However, this works as intended. The nosec annotation
+	// tells the scanner to ignore this.
+	// #nosec
+	cmd := exec.CommandContext(ctx, cfg.Cmd, args...)
+	cmd.Env = []string{}
+	for _, key := range passthroughEnvVars {
+		if value := os.Getenv(key); value != "" {
+			cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", key, value))
+		}
+	}
+	cmd.Env = append(cmd.Env, cfg.Env...)
+	cmd.Dir = ctx.Config.Dist
+
+	var b bytes.Buffer
+	w := gio.Safe(&b)
+	cmd.Stderr = io.MultiWriter(logext.NewWriter(fields, logext.Error), w)
+	cmd.Stdout = io.MultiWriter(logext.NewWriter(fields, logext.Info), w)
+
+	log.WithFields(fields).Info("cataloging")
+	if err := cmd.Run(); err != nil {
+		return nil, fmt.Errorf("cataloging artifacts: %s failed: %w: %s", cfg.Cmd, err, b.String())
+	}
+
+	var artifacts []*artifact.Artifact
+
+	for _, sbom := range cfg.Documents {
+		templater = tmpl.New(ctx).WithEnv(env)
+		if a != nil {
+			env["artifact"] = a.Name
+			templater = templater.WithArtifact(a, nil)
+		}
+
+		name, err := templater.Apply(expand(sbom, env))
+		if err != nil {
+			return nil, fmt.Errorf("cataloging artifacts failed: %s: invalid template: %w", a, err)
+		}
+
+		search := filepath.Join(ctx.Config.Dist, name)
+		matches, err := filepath.Glob(search)
+		if err != nil {
+			return nil, fmt.Errorf("cataloging artifacts: failed to find SBOM artifact %q: %w", search, err)
+		}
+		for _, match := range matches {
+			artifacts = append(artifacts, &artifact.Artifact{
+				Type: artifact.SBOM,
+				Name: name,
+				Path: match,
+				Extra: map[string]interface{}{
+					artifact.ExtraID: cfg.ID,
+				},
+			})
+		}
+
+	}
+
+	return artifacts, nil
+}
+
+func expand(s string, env map[string]string) string {
+	return os.Expand(s, func(key string) string {
+		return env[key]
+	})
+}
diff --git a/internal/pipe/sign/sign.go b/internal/pipe/sign/sign.go
index 7f77a16f..f073f10d 100644
--- a/internal/pipe/sign/sign.go
+++ b/internal/pipe/sign/sign.go
@@ -76,11 +76,14 @@ func (Pipe) Run(ctx *context.Context) error {
 					artifact.ByType(artifact.UploadableSourceArchive),
 					artifact.ByType(artifact.Checksum),
 					artifact.ByType(artifact.LinuxPackage),
+					artifact.ByType(artifact.SBOM),
 				))
 			case "archive":
 				filters = append(filters, artifact.ByType(artifact.UploadableArchive))
 			case "binary":
 				filters = append(filters, artifact.ByType(artifact.UploadableBinary))
+			case "sbom":
+				filters = append(filters, artifact.ByType(artifact.SBOM))
 			case "package":
 				filters = append(filters, artifact.ByType(artifact.LinuxPackage))
 			case "none": // TODO(caarlos0): this is not very useful, lets remove it.
@@ -200,7 +203,7 @@ func signone(ctx *context.Context, cfg config.Sign, art *artifact.Artifact) ([]*
 		return nil, nil
 	}
 
-	// re-execute template results, using artifact name as artifact so they eval to the actual needed file name.
+	// re-execute template results, using artifact desc as artifact so they eval to the actual needed file desc.
 	env["artifact"] = art.Name
 	name, _ = tmpl.New(ctx).WithEnv(env).Apply(expand(cfg.Signature, env))   // could never error as it passed the previous check
 	cert, _ = tmpl.New(ctx).WithEnv(env).Apply(expand(cfg.Certificate, env)) // could never error as it passed the previous check
diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go
index 31d6281c..d8da0d8f 100644
--- a/internal/pipeline/pipeline.go
+++ b/internal/pipeline/pipeline.go
@@ -23,6 +23,7 @@ import (
 	"github.com/goreleaser/goreleaser/internal/pipe/krew"
 	"github.com/goreleaser/goreleaser/internal/pipe/nfpm"
 	"github.com/goreleaser/goreleaser/internal/pipe/publish"
+	"github.com/goreleaser/goreleaser/internal/pipe/sbom"
 	"github.com/goreleaser/goreleaser/internal/pipe/scoop"
 	"github.com/goreleaser/goreleaser/internal/pipe/semver"
 	"github.com/goreleaser/goreleaser/internal/pipe/sign"
@@ -75,6 +76,7 @@ var Pipeline = append(
 	gofish.Pipe{},        // create gofish rig
 	krew.Pipe{},          // krew plugins
 	scoop.Pipe{},         // create scoop buckets
+	sbom.Pipe{},          // create SBOMs of artifacts
 	checksums.Pipe{},     // checksums of the files
 	sign.Pipe{},          // sign artifacts
 	docker.Pipe{},        // create and push docker images
diff --git a/pkg/config/config.go b/pkg/config/config.go
index b0d4ea48..9c1bc06e 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -619,6 +619,17 @@ type NFPMOverridables struct {
 	APK              NFPMAPK           `yaml:"apk,omitempty"`
 }
 
+// SBOM config.
+type SBOM struct {
+	ID        string   `yaml:"id,omitempty"`
+	Cmd       string   `yaml:"cmd,omitempty"`
+	Env       []string `yaml:"env,omitempty"`
+	Args      []string `yaml:"args,omitempty"`
+	Documents []string `yaml:"documents,omitempty"`
+	Artifacts string   `yaml:"artifacts,omitempty"`
+	IDs       []string `yaml:"ids,omitempty"`
+}
+
 // Sign config.
 type Sign struct {
 	ID          string   `yaml:"id,omitempty"`
@@ -836,6 +847,7 @@ type Project struct {
 	Source          Source           `yaml:"source,omitempty"`
 	GoMod           GoMod            `yaml:"gomod,omitempty"`
 	Announce        Announce         `yaml:"announce,omitempty"`
+	SBOMs           []SBOM           `yaml:"sboms,omitempty"`
 
 	UniversalBinaries []UniversalBinary `yaml:"universal_binaries,omitempty"`
 
diff --git a/pkg/context/context.go b/pkg/context/context.go
index 3b87142e..f2000c45 100644
--- a/pkg/context/context.go
+++ b/pkg/context/context.go
@@ -92,6 +92,7 @@ type Context struct {
 	SkipAnnounce       bool
 	SkipSign           bool
 	SkipValidate       bool
+	SkipSBOMCataloging bool
 	RmDist             bool
 	PreRelease         bool
 	Deprecated         bool
diff --git a/pkg/defaults/defaults.go b/pkg/defaults/defaults.go
index 5ad3bf40..a5f751f7 100644
--- a/pkg/defaults/defaults.go
+++ b/pkg/defaults/defaults.go
@@ -23,6 +23,7 @@ import (
 	"github.com/goreleaser/goreleaser/internal/pipe/project"
 	"github.com/goreleaser/goreleaser/internal/pipe/reddit"
 	"github.com/goreleaser/goreleaser/internal/pipe/release"
+	"github.com/goreleaser/goreleaser/internal/pipe/sbom"
 	"github.com/goreleaser/goreleaser/internal/pipe/scoop"
 	"github.com/goreleaser/goreleaser/internal/pipe/sign"
 	"github.com/goreleaser/goreleaser/internal/pipe/slack"
@@ -62,6 +63,7 @@ var Defaulters = []Defaulter{
 	checksums.Pipe{},
 	sign.Pipe{},
 	sign.DockerPipe{},
+	sbom.Pipe{},
 	docker.Pipe{},
 	docker.ManifestPipe{},
 	artifactory.Pipe{},
diff --git a/www/docs/customization/sbom.md b/www/docs/customization/sbom.md
new file mode 100644
index 00000000..95742cb1
--- /dev/null
+++ b/www/docs/customization/sbom.md
@@ -0,0 +1,102 @@
+# Cataloging artifacts
+
+A Software Bill of Materials (SBOM) is a description of the components that make up a software artifact.
+
+Goreleaser can create one or more SBOMs for any artifacts generated by Goreleaser.
+
+## Usage
+
+The artifact cataloging step can analyze one or more artifacts generated by Goreleaser and output one or more
+SBOM files into the dist directory.
+
+The default is configured to create an SBOM for each binary produced with [Syft](https://github.com/anchore/syft).
+To enable artifact cataloging just add:
+
+```yaml
+# .goreleaser.yml
+sboms:
+  - artifacts: archive
+```
+
+To customize the artifact cataloging pipeline you can use the following options:
+
+```yaml
+# .goreleaser.yml
+sboms:
+  -
+    # ID of the sbom config, must be unique.
+    #
+    # Defaults to "default".
+    id: foo
+
+    # List of Names/templates of the SBOM documents created at this step (relative to the dist dir).
+    #
+    # Each element configured is made available as variables. For example:
+    #   documents: ["foo", "bar"]
+    #
+    # would make the following variables that can be referenced as template keys:
+    #   document0: "foo"
+    #   document1: "bar"
+    #
+    # Default value is conditional based on the value of "artifacts"
+    #   - "binary":   ["{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.sbom"]
+    #   - "any":      []
+    #   - otherwise:  ["{{ .ArtifactName }}.sbom"]
+    #
+    # Note that multiple sbom values are only allowed if the value of "artifacts" is "any".
+    documents:
+      - "${artifact}.spdx.sbom"
+
+    # Path to the SBOM generator command
+    #
+    # Note: the process CWD will be set to the same location as "dist"
+    #
+    # Defaults to `syft`
+    cmd: syft
+
+    # Command line templateable arguments for the command
+    #
+    # Defaults to `["$artifact", "--file", "$document", "--output", "spdx-json"]`
+    args: ["$artifact", "--file", "$sbom", "--output", "spdx-json"]
+
+    # List of environment variables that will be passed to the SBOM command as well as the templates.
+    #
+    # Defaults to [ "SYFT_FILE_METADATA_CATALOGER_ENABLED=true" ]
+    env:
+      - FOO=bar
+      - HONK=honkhonk
+
+    # Which artifacts to catalog
+    #
+    #   any:      let the SBOM tool decide what artifacts available in the cwd should be cataloged
+    #   source:   source archive
+    #   package:  linux packages (deb, rpm, apk)
+    #   archive:  archives from archive pipe
+    #   binary:   binaries output from the build stage
+    #
+    # Defaults to `archive`
+    artifacts: archive
+
+    # IDs of the artifacts to catalog.
+    #
+    # If `artifacts` is "source" or "any" then this fields has no effect.
+    #
+    # Defaults to empty (which implies no filtering).
+    ids:
+      - foo
+      - bar
+```
+
+### Available variable names
+
+These environment variables might be available in the fields that are templateable:
+
+- `${artifact}`: the path to the artifact that will be cataloged (unless "artifacts" config item is "any")
+- `${artifactID}`: the ID of the artifact that will be cataloged (unless "artifacts" config item is "any")
+- `${document}`:  the SBOM filename generated (corresponds to `${document0}` if the "artifacts" config item is "any")
+- `${document#}`: the SBOM filenames generated, where `#` corresponds to the list index under the "documents" config item (e.g. `${document0}`)
+
+## Limitations
+
+Container images generated by Goreleaser are not available to be cataloged by the SBOM tool.
+
diff --git a/www/docs/customization/sign.md b/www/docs/customization/sign.md
index 8c334067..bce52c2f 100644
--- a/www/docs/customization/sign.md
+++ b/www/docs/customization/sign.md
@@ -60,6 +60,7 @@ signs:
     #   package:  linux packages (deb, rpm, apk)
     #   archive:  archives from archive pipe
     #   binary:   binaries if archiving format is set to binary
+    #   sbom:     any Software Bill of Materials generated for other artifacts
     #
     # Defaults to `none`
     artifacts: all
diff --git a/www/mkdocs.yml b/www/mkdocs.yml
index 4e5f924d..478724dc 100644
--- a/www/mkdocs.yml
+++ b/www/mkdocs.yml
@@ -91,6 +91,7 @@ nav:
     - customization/snapcraft.md
     - customization/docker.md
     - customization/docker_manifest.md
+  - customization/sbom.md
   - Signing:
     - Checksums and artifacts: customization/sign.md
     - Docker Images and Manifests: customization/docker_sign.md


Test patch:
diff --git a/internal/artifact/artifact_test.go b/internal/artifact/artifact_test.go
index 56cacd79..753c2e68 100644
--- a/internal/artifact/artifact_test.go
+++ b/internal/artifact/artifact_test.go
@@ -385,6 +385,7 @@ func TestTypeToString(t *testing.T) {
 		GoFishRig,
 		KrewPluginManifest,
 		ScoopManifest,
+		SBOM,
 	} {
 		t.Run(a.String(), func(t *testing.T) {
 			require.NotEqual(t, "unknown", a.String())
diff --git a/internal/pipe/sbom/sbom_test.go b/internal/pipe/sbom/sbom_test.go
new file mode 100644
index 00000000..cad4226d
--- /dev/null
+++ b/internal/pipe/sbom/sbom_test.go
@@ -0,0 +1,593 @@
+package sbom
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"sort"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/goreleaser/goreleaser/internal/artifact"
+
+	"github.com/goreleaser/goreleaser/pkg/config"
+	"github.com/goreleaser/goreleaser/pkg/context"
+	"github.com/stretchr/testify/require"
+)
+
+func TestDescription(t *testing.T) {
+	require.NotEmpty(t, Pipe{}.String())
+}
+
+func TestSBOMCatalogDefault(t *testing.T) {
+	defaultArgs := []string{"$artifact", "--file", "$document", "--output", "spdx-json"}
+	defaultSboms := []string{
+		"{{ .ArtifactName }}.sbom",
+	}
+	defaultCmd := "syft"
+	tests := []struct {
+		configs  []config.SBOM
+		artifact string
+		cmd      string
+		sboms    []string
+		args     []string
+		env      []string
+		err      bool
+	}{
+		{
+			configs: []config.SBOM{
+				{
+					// empty
+				},
+			},
+			artifact: "archive",
+			cmd:      defaultCmd,
+			sboms:    defaultSboms,
+			args:     defaultArgs,
+			env: []string{
+				"SYFT_FILE_METADATA_CATALOGER_ENABLED=true",
+			},
+		},
+		{
+			configs: []config.SBOM{
+				{
+					Artifacts: "package",
+				},
+			},
+			artifact: "package",
+			cmd:      defaultCmd,
+			sboms:    defaultSboms,
+			args:     defaultArgs,
+		},
+		{
+			configs: []config.SBOM{
+				{
+					Artifacts: "archive",
+				},
+			},
+			artifact: "archive",
+			cmd:      defaultCmd,
+			sboms:    defaultSboms,
+			args:     defaultArgs,
+			env: []string{
+				"SYFT_FILE_METADATA_CATALOGER_ENABLED=true",
+			},
+		},
+		{
+			configs: []config.SBOM{
+				{
+					Artifacts: "any",
+				},
+			},
+			artifact: "any",
+			cmd:      defaultCmd,
+			sboms:    []string{},
+			args:     defaultArgs,
+		},
+		{
+			configs: []config.SBOM{
+				{
+					Artifacts: "binary",
+				},
+			},
+			artifact: "binary",
+			cmd:      defaultCmd,
+			sboms:    []string{"{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.sbom"},
+			args:     defaultArgs,
+		},
+		{
+			configs: []config.SBOM{
+				{
+					Artifacts: "source",
+				},
+			},
+			artifact: "source",
+			cmd:      defaultCmd,
+			sboms:    defaultSboms,
+			args:     defaultArgs,
+			env: []string{
+				"SYFT_FILE_METADATA_CATALOGER_ENABLED=true",
+			},
+		},
+		{
+			// multiple documents are not allowed when artifacts != "any"
+			configs: []config.SBOM{
+				{
+					Artifacts: "binary",
+					Documents: []string{
+						"doc1",
+						"doc2",
+					},
+				},
+			},
+			err: true,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(fmt.Sprintf("artifact=%q", test.configs[0].Artifacts), func(t *testing.T) {
+			ctx := &context.Context{
+				Config: config.Project{
+					SBOMs: test.configs,
+				},
+			}
+			err := Pipe{}.Default(ctx)
+			if test.err {
+				require.Error(t, err)
+				return
+			}
+			require.NoError(t, err)
+			require.Equal(t, ctx.Config.SBOMs[0].Cmd, test.cmd)
+			require.Equal(t, ctx.Config.SBOMs[0].Documents, test.sboms)
+			require.Equal(t, ctx.Config.SBOMs[0].Args, test.args)
+			require.Equal(t, ctx.Config.SBOMs[0].Env, test.env)
+			require.Equal(t, ctx.Config.SBOMs[0].Artifacts, test.artifact)
+		})
+	}
+}
+
+func TestSBOMCatalogInvalidArtifacts(t *testing.T) {
+	ctx := context.New(config.Project{})
+	ctx.Config.SBOMs = []config.SBOM{
+		{Artifacts: "foo"},
+	}
+	err := Pipe{}.Run(ctx)
+	require.EqualError(t, err, "invalid list of artifacts to catalog: foo")
+}
+
+func TestSeveralSBOMsWithTheSameID(t *testing.T) {
+	ctx := &context.Context{
+		Config: config.Project{
+			SBOMs: []config.SBOM{
+				{
+					ID: "a",
+				},
+				{
+					ID: "a",
+				},
+			},
+		},
+	}
+	require.EqualError(t, Pipe{}.Default(ctx), "found 2 sboms with the ID 'a', please fix your config")
+}
+
+func TestSkipCataloging(t *testing.T) {
+	t.Run("skip", func(t *testing.T) {
+		require.True(t, Pipe{}.Skip(context.New(config.Project{})))
+	})
+
+	t.Run("skip SBOM cataloging", func(t *testing.T) {
+		ctx := context.New(config.Project{
+			SBOMs: []config.SBOM{
+				{
+					Artifacts: "all",
+				},
+			},
+		})
+		ctx.SkipSBOMCataloging = true
+		require.True(t, Pipe{}.Skip(ctx))
+	})
+
+	t.Run("dont skip", func(t *testing.T) {
+		ctx := context.New(config.Project{
+			SBOMs: []config.SBOM{
+				{
+					Artifacts: "all",
+				},
+			},
+		})
+		require.False(t, Pipe{}.Skip(ctx))
+	})
+}
+
+func TestSBOMCatalogArtifacts(t *testing.T) {
+	tests := []struct {
+		desc           string
+		ctx            *context.Context
+		sbomPaths      []string
+		sbomNames      []string
+		expectedErrMsg string
+	}{
+		{
+			desc:           "catalog errors",
+			expectedErrMsg: "cataloging artifacts: exit failed",
+			ctx: context.New(
+				config.Project{
+					SBOMs: []config.SBOM{
+						{
+							Artifacts: "binary",
+							Cmd:       "exit",
+							Args:      []string{"1"},
+						},
+					},
+				},
+			),
+		},
+		{
+			desc:           "invalid args template",
+			expectedErrMsg: `cataloging artifacts failed: ${FOO}-{{ .foo }{{}}{: invalid template: template: tmpl:1: unexpected "}" in operand`,
+			ctx: context.New(
+				config.Project{
+					SBOMs: []config.SBOM{
+						{
+							Artifacts: "binary",
+							Cmd:       "exit",
+							Args:      []string{"${FOO}-{{ .foo }{{}}{"},
+						},
+					},
+					Env: []string{
+						"FOO=BAR",
+					},
+				},
+			),
+		},
+		{
+			desc: "catalog source archives",
+			ctx: context.New(
+				config.Project{
+					SBOMs: []config.SBOM{
+						{Artifacts: "source"},
+					},
+				},
+			),
+			sbomPaths: []string{"artifact5.tar.gz.sbom"},
+			sbomNames: []string{"artifact5.tar.gz.sbom"},
+		},
+		{
+			desc: "catalog archives",
+			ctx: context.New(
+				config.Project{
+					SBOMs: []config.SBOM{
+						{Artifacts: "archive"},
+					},
+				},
+			),
+			sbomPaths: []string{"artifact1.sbom", "artifact2.sbom"},
+			sbomNames: []string{"artifact1.sbom", "artifact2.sbom"},
+		},
+		{
+			desc: "catalog linux packages",
+			ctx: context.New(
+				config.Project{
+					SBOMs: []config.SBOM{
+						{Artifacts: "package"},
+					},
+				},
+			),
+			sbomPaths: []string{"package1.deb.sbom"},
+			sbomNames: []string{"package1.deb.sbom"},
+		},
+		{
+			desc: "catalog binaries",
+			ctx: context.New(
+				config.Project{
+					SBOMs: []config.SBOM{
+						{Artifacts: "binary"},
+					},
+				},
+			),
+			sbomPaths: []string{
+				"artifact3-name_1.2.2_linux_amd64.sbom",
+				"artifact4-name_1.2.2_linux_amd64.sbom",
+			},
+			sbomNames: []string{
+				"artifact3-name_1.2.2_linux_amd64.sbom",
+				"artifact4-name_1.2.2_linux_amd64.sbom",
+			},
+		},
+		{
+			desc: "manual cataloging",
+			ctx: context.New(
+				config.Project{
+					SBOMs: []config.SBOM{
+						{
+							Artifacts: "any",
+							Args: []string{
+								"--file",
+								"$document0",
+								"--output",
+								"spdx-json",
+								"artifact5.tar.gz",
+							},
+							Documents: []string{
+								"final.sbom",
+							},
+						},
+					},
+				},
+			),
+			sbomPaths: []string{"final.sbom"},
+			sbomNames: []string{"final.sbom"},
+		},
+		{
+			desc: "multiple SBOM configs",
+			ctx: context.New(
+				config.Project{
+					Env: []string{
+						"SBOM_SUFFIX=s2-ish",
+					},
+					SBOMs: []config.SBOM{
+						{
+							ID:        "s1",
+							Artifacts: "binary",
+						},
+						{
+							ID:        "s2",
+							Artifacts: "archive",
+							Documents: []string{"{{ .ArtifactName }}.{{ .Env.SBOM_SUFFIX }}.sbom"},
+						},
+					},
+				},
+			),
+			sbomPaths: []string{
+				"artifact1.s2-ish.sbom",
+				"artifact2.s2-ish.sbom",
+				"artifact3-name_1.2.2_linux_amd64.sbom",
+				"artifact4-name_1.2.2_linux_amd64.sbom",
+			},
+			sbomNames: []string{
+				"artifact1.s2-ish.sbom",
+				"artifact2.s2-ish.sbom",
+				"artifact3-name_1.2.2_linux_amd64.sbom",
+				"artifact4-name_1.2.2_linux_amd64.sbom",
+			},
+		},
+		{
+			desc: "catalog artifacts with filtered by ID",
+			ctx: context.New(
+				config.Project{
+					SBOMs: []config.SBOM{
+						{
+							Artifacts: "binary",
+							IDs:       []string{"foo"},
+						},
+					},
+				},
+			),
+			sbomPaths: []string{
+				"artifact3-name_1.2.2_linux_amd64.sbom",
+			},
+			sbomNames: []string{
+				"artifact3-name_1.2.2_linux_amd64.sbom",
+			},
+		},
+		{
+			desc: "catalog binary artifacts with env in arguments",
+			ctx: context.New(
+				config.Project{
+					SBOMs: []config.SBOM{
+						{
+							Artifacts: "binary",
+							Args: []string{
+								"--file",
+								"$document",
+								"--output",
+								"spdx-json",
+								"$artifact",
+							},
+							Documents: []string{
+								"{{ .ArtifactName }}.{{ .Env.TEST_USER }}.sbom",
+							},
+						},
+					},
+					Env: []string{
+						"TEST_USER=test-user-name",
+					},
+				},
+			),
+			sbomPaths: []string{
+				"artifact3-name.test-user-name.sbom",
+				"artifact4.test-user-name.sbom",
+			},
+			sbomNames: []string{
+				"artifact3-name.test-user-name.sbom",
+				"artifact4.test-user-name.sbom",
+			},
+		},
+		{
+			desc: "cataloging 'any' artifacts fails",
+			ctx: context.New(
+				config.Project{
+					SBOMs: []config.SBOM{
+						{
+							Artifacts: "any",
+							Cmd:       "false",
+						},
+					},
+				},
+			),
+			expectedErrMsg: "cataloging artifacts: false failed: exit status 1: ",
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.desc, func(t *testing.T) {
+			testSBOMCataloging(t, test.ctx, test.sbomPaths, test.sbomNames, test.expectedErrMsg)
+		})
+	}
+}
+
+func testSBOMCataloging(tb testing.TB, ctx *context.Context, sbomPaths, sbomNames []string, expectedErrMsg string) {
+	tb.Helper()
+	tmpdir := tb.TempDir()
+
+	ctx.Config.Dist = tmpdir
+	ctx.Version = "1.2.2"
+
+	// create some fake artifacts
+	artifacts := []string{"artifact1", "artifact2", "artifact3", "package1.deb"}
+	require.NoError(tb, os.Mkdir(filepath.Join(tmpdir, "linux_amd64"), os.ModePerm))
+	for _, f := range artifacts {
+		file := filepath.Join(tmpdir, f)
+		require.NoError(tb, os.WriteFile(file, []byte("foo"), 0o644))
+	}
+	require.NoError(tb, os.WriteFile(filepath.Join(tmpdir, "linux_amd64", "artifact4"), []byte("foo"), 0o644))
+	artifacts = append(artifacts, "linux_amd64/artifact4")
+	require.NoError(tb, os.WriteFile(filepath.Join(tmpdir, "artifact5.tar.gz"), []byte("foo"), 0o644))
+	artifacts = append(artifacts, "artifact5.tar.gz")
+	ctx.Artifacts.Add(&artifact.Artifact{
+		Name: "artifact1",
+		Path: filepath.Join(tmpdir, "artifact1"),
+		Type: artifact.UploadableArchive,
+		Extra: map[string]interface{}{
+			artifact.ExtraID: "foo",
+		},
+	})
+	ctx.Artifacts.Add(&artifact.Artifact{
+		Name: "artifact2",
+		Path: filepath.Join(tmpdir, "artifact2"),
+		Type: artifact.UploadableArchive,
+		Extra: map[string]interface{}{
+			artifact.ExtraID: "foo3",
+		},
+	})
+	ctx.Artifacts.Add(&artifact.Artifact{
+		Name:   "artifact3-name",
+		Path:   filepath.Join(tmpdir, "artifact3"),
+		Goos:   "linux",
+		Goarch: "amd64",
+		Type:   artifact.UploadableBinary,
+		Extra: map[string]interface{}{
+			artifact.ExtraID:     "foo",
+			artifact.ExtraBinary: "artifact3-name",
+		},
+	})
+	ctx.Artifacts.Add(&artifact.Artifact{
+		Name:   "artifact4",
+		Path:   filepath.Join(tmpdir, "linux_amd64", "artifact4"),
+		Goos:   "linux",
+		Goarch: "amd64",
+		Type:   artifact.Binary,
+		Extra: map[string]interface{}{
+			artifact.ExtraID:     "foo3",
+			artifact.ExtraBinary: "artifact4-name",
+		},
+	})
+	ctx.Artifacts.Add(&artifact.Artifact{
+		Name: "artifact5.tar.gz",
+		Path: filepath.Join(tmpdir, "artifact5.tar.gz"),
+		Type: artifact.UploadableSourceArchive,
+	})
+	ctx.Artifacts.Add(&artifact.Artifact{
+		Name: "package1.deb",
+		Path: filepath.Join(tmpdir, "package1.deb"),
+		Type: artifact.LinuxPackage,
+		Extra: map[string]interface{}{
+			artifact.ExtraID: "foo",
+		},
+	})
+
+	// configure the pipeline
+	require.NoError(tb, Pipe{}.Default(ctx))
+
+	// run the pipeline
+	if expectedErrMsg != "" {
+		err := Pipe{}.Run(ctx)
+		require.Error(tb, err)
+		require.Contains(tb, err.Error(), expectedErrMsg)
+		return
+	}
+
+	require.NoError(tb, Pipe{}.Run(ctx))
+
+	// ensure all artifacts have an ID
+	for _, arti := range ctx.Artifacts.Filter(artifact.ByType(artifact.SBOM)).List() {
+		require.NotEmptyf(tb, arti.ID(), ".Extra.ID on %s", arti.Path)
+	}
+
+	// verify that only the artifacts and the sboms are in the dist dir
+	gotFiles := []string{}
+
+	require.NoError(tb, filepath.Walk(tmpdir,
+		func(path string, info os.FileInfo, err error) error {
+			if err != nil {
+				return err
+			}
+			if info.IsDir() {
+				return nil
+			}
+			relPath, err := filepath.Rel(tmpdir, path)
+			if err != nil {
+				return err
+			}
+			gotFiles = append(gotFiles, relPath)
+			return nil
+		}),
+	)
+
+	wantFiles := append(artifacts, sbomPaths...)
+	sort.Strings(wantFiles)
+	require.ElementsMatch(tb, wantFiles, gotFiles, "SBOM paths differ")
+
+	var sbomArtifacts []string
+	for _, sig := range ctx.Artifacts.Filter(artifact.ByType(artifact.SBOM)).List() {
+		sbomArtifacts = append(sbomArtifacts, sig.Name)
+	}
+
+	require.ElementsMatch(tb, sbomArtifacts, sbomNames, "SBOM names differ")
+}
+
+func Test_subprocessDistPath(t *testing.T) {
+	cwd, err := os.Getwd()
+	require.NoError(t, err)
+
+	tests := []struct {
+		name              string
+		distDir           string
+		pathRelativeToCwd string
+		expects           string
+	}{
+		{
+			name:              "relative dist with anchor",
+			distDir:           "./dist",
+			pathRelativeToCwd: "dist/my.sbom",
+			expects:           "my.sbom",
+		},
+		{
+			name:              "relative dist without anchor",
+			distDir:           "dist",
+			pathRelativeToCwd: "dist/my.sbom",
+			expects:           "my.sbom",
+		},
+		{
+			name:              "relative dist with nested resource",
+			distDir:           "dist",
+			pathRelativeToCwd: "dist/something/my.sbom",
+			expects:           "something/my.sbom",
+		},
+		{
+			name:              "absolute dist with nested resource",
+			distDir:           filepath.Join(cwd, "dist/"),
+			pathRelativeToCwd: "dist/something/my.sbom",
+			expects:           "something/my.sbom",
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			actual, err := subprocessDistPath(test.distDir, test.pathRelativeToCwd)
+			require.NoError(t, err)
+			assert.Equal(t, test.expects, actual)
+		})
+	}
+}
diff --git a/internal/pipe/sign/sign_test.go b/internal/pipe/sign/sign_test.go
index c34bb8d2..9e66deb9 100644
--- a/internal/pipe/sign/sign_test.go
+++ b/internal/pipe/sign/sign_test.go
@@ -12,6 +12,8 @@ import (
 	"testing"
 	"time"
 
+	"github.com/stretchr/testify/assert"
+
 	"github.com/goreleaser/goreleaser/internal/artifact"
 	"github.com/goreleaser/goreleaser/pkg/config"
 	"github.com/goreleaser/goreleaser/pkg/context"
@@ -183,18 +185,6 @@ func TestSignArtifacts(t *testing.T) {
 				},
 			),
 		},
-		{
-			desc: "sign single",
-			ctx: context.New(
-				config.Project{
-					Signs: []config.Sign{
-						{Artifacts: "all"},
-					},
-				},
-			),
-			signaturePaths: []string{"artifact1.sig", "artifact2.sig", "artifact3.sig", "checksum.sig", "checksum2.sig", "linux_amd64/artifact4.sig", "artifact5.tar.gz.sig", "package1.deb.sig"},
-			signatureNames: []string{"artifact1.sig", "artifact2.sig", "artifact3_1.0.0_linux_amd64.sig", "checksum.sig", "checksum2.sig", "artifact4_1.0.0_linux_amd64.sig", "artifact5.tar.gz.sig", "package1.deb.sig"},
-		},
 		{
 			desc: "sign all artifacts",
 			ctx: context.New(
@@ -206,8 +196,8 @@ func TestSignArtifacts(t *testing.T) {
 					},
 				},
 			),
-			signaturePaths: []string{"artifact1.sig", "artifact2.sig", "artifact3.sig", "checksum.sig", "checksum2.sig", "linux_amd64/artifact4.sig", "artifact5.tar.gz.sig", "package1.deb.sig"},
-			signatureNames: []string{"artifact1.sig", "artifact2.sig", "artifact3_1.0.0_linux_amd64.sig", "checksum.sig", "checksum2.sig", "artifact4_1.0.0_linux_amd64.sig", "artifact5.tar.gz.sig", "package1.deb.sig"},
+			signaturePaths: []string{"artifact1.sig", "artifact2.sig", "artifact3.sig", "checksum.sig", "checksum2.sig", "linux_amd64/artifact4.sig", "artifact5.tar.gz.sig", "artifact5.tar.gz.sbom.sig", "package1.deb.sig"},
+			signatureNames: []string{"artifact1.sig", "artifact2.sig", "artifact3_1.0.0_linux_amd64.sig", "checksum.sig", "checksum2.sig", "artifact4_1.0.0_linux_amd64.sig", "artifact5.tar.gz.sig", "artifact5.tar.gz.sbom.sig", "package1.deb.sig"},
 		},
 		{
 			desc: "sign archives",
@@ -357,6 +347,20 @@ func TestSignArtifacts(t *testing.T) {
 			signaturePaths: []string{"artifact5.tar.gz.sig"},
 			signatureNames: []string{"artifact5.tar.gz.sig"},
 		},
+		{
+			desc: "sign only sbom",
+			ctx: context.New(
+				config.Project{
+					Signs: []config.Sign{
+						{
+							Artifacts: "sbom",
+						},
+					},
+				},
+			),
+			signaturePaths: []string{"artifact5.tar.gz.sbom.sig"},
+			signatureNames: []string{"artifact5.tar.gz.sbom.sig"},
+		},
 		{
 			desc: "sign all artifacts with env",
 			ctx: context.New(
@@ -379,8 +383,8 @@ func TestSignArtifacts(t *testing.T) {
 					},
 				},
 			),
-			signaturePaths: []string{"artifact1.sig", "artifact2.sig", "artifact3.sig", "checksum.sig", "checksum2.sig", "linux_amd64/artifact4.sig", "artifact5.tar.gz.sig", "package1.deb.sig"},
-			signatureNames: []string{"artifact1.sig", "artifact2.sig", "artifact3_1.0.0_linux_amd64.sig", "checksum.sig", "checksum2.sig", "artifact4_1.0.0_linux_amd64.sig", "artifact5.tar.gz.sig", "package1.deb.sig"},
+			signaturePaths: []string{"artifact1.sig", "artifact2.sig", "artifact3.sig", "checksum.sig", "checksum2.sig", "linux_amd64/artifact4.sig", "artifact5.tar.gz.sig", "artifact5.tar.gz.sbom.sig", "package1.deb.sig"},
+			signatureNames: []string{"artifact1.sig", "artifact2.sig", "artifact3_1.0.0_linux_amd64.sig", "checksum.sig", "checksum2.sig", "artifact4_1.0.0_linux_amd64.sig", "artifact5.tar.gz.sig", "artifact5.tar.gz.sbom.sig", "package1.deb.sig"},
 		},
 		{
 			desc: "sign all artifacts with template",
@@ -404,8 +408,8 @@ func TestSignArtifacts(t *testing.T) {
 					},
 				},
 			),
-			signaturePaths: []string{"artifact1.sig", "artifact2.sig", "artifact3.sig", "checksum.sig", "checksum2.sig", "linux_amd64/artifact4.sig", "artifact5.tar.gz.sig", "package1.deb.sig"},
-			signatureNames: []string{"artifact1.sig", "artifact2.sig", "artifact3_1.0.0_linux_amd64.sig", "checksum.sig", "checksum2.sig", "artifact4_1.0.0_linux_amd64.sig", "artifact5.tar.gz.sig", "package1.deb.sig"},
+			signaturePaths: []string{"artifact1.sig", "artifact2.sig", "artifact3.sig", "checksum.sig", "checksum2.sig", "linux_amd64/artifact4.sig", "artifact5.tar.gz.sig", "artifact5.tar.gz.sbom.sig", "package1.deb.sig"},
+			signatureNames: []string{"artifact1.sig", "artifact2.sig", "artifact3_1.0.0_linux_amd64.sig", "checksum.sig", "checksum2.sig", "artifact4_1.0.0_linux_amd64.sig", "artifact5.tar.gz.sig", "artifact5.tar.gz.sbom.sig", "package1.deb.sig"},
 		},
 		{
 			desc: "sign single with password from stdin",
@@ -432,8 +436,8 @@ func TestSignArtifacts(t *testing.T) {
 					},
 				},
 			),
-			signaturePaths: []string{"artifact1.sig", "artifact2.sig", "artifact3.sig", "checksum.sig", "checksum2.sig", "linux_amd64/artifact4.sig", "artifact5.tar.gz.sig", "package1.deb.sig"},
-			signatureNames: []string{"artifact1.sig", "artifact2.sig", "artifact3_1.0.0_linux_amd64.sig", "checksum.sig", "checksum2.sig", "artifact4_1.0.0_linux_amd64.sig", "artifact5.tar.gz.sig", "package1.deb.sig"},
+			signaturePaths: []string{"artifact1.sig", "artifact2.sig", "artifact3.sig", "checksum.sig", "checksum2.sig", "linux_amd64/artifact4.sig", "artifact5.tar.gz.sig", "artifact5.tar.gz.sbom.sig", "package1.deb.sig"},
+			signatureNames: []string{"artifact1.sig", "artifact2.sig", "artifact3_1.0.0_linux_amd64.sig", "checksum.sig", "checksum2.sig", "artifact4_1.0.0_linux_amd64.sig", "artifact5.tar.gz.sig", "artifact5.tar.gz.sbom.sig", "package1.deb.sig"},
 			user:           passwordUser,
 		},
 		{
@@ -462,8 +466,8 @@ func TestSignArtifacts(t *testing.T) {
 					},
 				},
 			),
-			signaturePaths: []string{"artifact1.sig", "artifact2.sig", "artifact3.sig", "checksum.sig", "checksum2.sig", "linux_amd64/artifact4.sig", "artifact5.tar.gz.sig", "package1.deb.sig"},
-			signatureNames: []string{"artifact1.sig", "artifact2.sig", "artifact3_1.0.0_linux_amd64.sig", "checksum.sig", "checksum2.sig", "artifact4_1.0.0_linux_amd64.sig", "artifact5.tar.gz.sig", "package1.deb.sig"},
+			signaturePaths: []string{"artifact1.sig", "artifact2.sig", "artifact3.sig", "checksum.sig", "checksum2.sig", "linux_amd64/artifact4.sig", "artifact5.tar.gz.sig", "artifact5.tar.gz.sbom.sig", "package1.deb.sig"},
+			signatureNames: []string{"artifact1.sig", "artifact2.sig", "artifact3_1.0.0_linux_amd64.sig", "checksum.sig", "checksum2.sig", "artifact4_1.0.0_linux_amd64.sig", "artifact5.tar.gz.sig", "artifact5.tar.gz.sbom.sig", "package1.deb.sig"},
 			user:           passwordUser,
 		},
 		{
@@ -491,8 +495,8 @@ func TestSignArtifacts(t *testing.T) {
 					},
 				},
 			),
-			signaturePaths: []string{"artifact1.sig", "artifact2.sig", "artifact3.sig", "checksum.sig", "checksum2.sig", "linux_amd64/artifact4.sig", "artifact5.tar.gz.sig", "package1.deb.sig"},
-			signatureNames: []string{"artifact1.sig", "artifact2.sig", "artifact3_1.0.0_linux_amd64.sig", "checksum.sig", "checksum2.sig", "artifact4_1.0.0_linux_amd64.sig", "artifact5.tar.gz.sig", "package1.deb.sig"},
+			signaturePaths: []string{"artifact1.sig", "artifact2.sig", "artifact3.sig", "checksum.sig", "checksum2.sig", "linux_amd64/artifact4.sig", "artifact5.tar.gz.sig", "artifact5.tar.gz.sbom.sig", "package1.deb.sig"},
+			signatureNames: []string{"artifact1.sig", "artifact2.sig", "artifact3_1.0.0_linux_amd64.sig", "checksum.sig", "checksum2.sig", "artifact4_1.0.0_linux_amd64.sig", "artifact5.tar.gz.sig", "artifact5.tar.gz.sbom.sig", "package1.deb.sig"},
 			user:           passwordUser,
 		},
 		{
@@ -545,9 +549,9 @@ func TestSignArtifacts(t *testing.T) {
 					},
 				},
 			),
-			signaturePaths:   []string{"artifact1.sig", "artifact2.sig", "artifact3.sig", "checksum.sig", "checksum2.sig", "linux_amd64/artifact4.sig", "artifact5.tar.gz.sig", "package1.deb.sig"},
-			signatureNames:   []string{"artifact1.sig", "artifact2.sig", "artifact3_1.0.0_linux_amd64.sig", "checksum.sig", "checksum2.sig", "artifact4_1.0.0_linux_amd64.sig", "artifact5.tar.gz.sig", "package1.deb.sig"},
-			certificateNames: []string{"artifact1_honk.pem", "artifact2_honk.pem", "artifact3_1.0.0_linux_amd64_honk.pem", "checksum_honk.pem", "checksum2_honk.pem", "artifact4_1.0.0_linux_amd64_honk.pem", "artifact5_honk.pem", "package1_honk.pem"},
+			signaturePaths:   []string{"artifact1.sig", "artifact2.sig", "artifact3.sig", "checksum.sig", "checksum2.sig", "linux_amd64/artifact4.sig", "artifact5.tar.gz.sig", "artifact5.tar.gz.sbom.sig", "package1.deb.sig"},
+			signatureNames:   []string{"artifact1.sig", "artifact2.sig", "artifact3_1.0.0_linux_amd64.sig", "checksum.sig", "checksum2.sig", "artifact4_1.0.0_linux_amd64.sig", "artifact5.tar.gz.sig", "artifact5.tar.gz.sbom.sig", "package1.deb.sig"},
+			certificateNames: []string{"artifact1_honk.pem", "artifact2_honk.pem", "artifact3_1.0.0_linux_amd64_honk.pem", "checksum_honk.pem", "checksum2_honk.pem", "artifact4_1.0.0_linux_amd64_honk.pem", "artifact5_honk.pem", "artifact5.tar.gz.sbom_honk.pem", "package1_honk.pem"},
 		},
 	}
 
@@ -579,6 +583,8 @@ func testSign(tb testing.TB, ctx *context.Context, certificateNames, signaturePa
 	artifacts = append(artifacts, "linux_amd64/artifact4")
 	require.NoError(tb, os.WriteFile(filepath.Join(tmpdir, "artifact5.tar.gz"), []byte("foo"), 0o644))
 	artifacts = append(artifacts, "artifact5.tar.gz")
+	require.NoError(tb, os.WriteFile(filepath.Join(tmpdir, "artifact5.tar.gz.sbom"), []byte("sbom(foo)"), 0o644))
+	artifacts = append(artifacts, "artifact5.tar.gz.sbom")
 	ctx.Artifacts.Add(&artifact.Artifact{
 		Name: "artifact1",
 		Path: filepath.Join(tmpdir, "artifact1"),
@@ -632,6 +638,11 @@ func testSign(tb testing.TB, ctx *context.Context, certificateNames, signaturePa
 		Path: filepath.Join(tmpdir, "artifact5.tar.gz"),
 		Type: artifact.UploadableSourceArchive,
 	})
+	ctx.Artifacts.Add(&artifact.Artifact{
+		Name: "artifact5.tar.gz.sbom",
+		Path: filepath.Join(tmpdir, "artifact5.tar.gz.sbom"),
+		Type: artifact.SBOM,
+	})
 	ctx.Artifacts.Add(&artifact.Artifact{
 		Name: "package1.deb",
 		Path: filepath.Join(tmpdir, "package1.deb"),
@@ -677,12 +688,8 @@ func testSign(tb testing.TB, ctx *context.Context, certificateNames, signaturePa
 		certNames = append(certNames, cert.Name)
 		require.True(tb, strings.HasPrefix(cert.Path, ctx.Config.Dist))
 	}
-	sort.Strings(certificateNames)
-	sort.Strings(certNames)
-	require.Equal(tb, len(certificateNames), len(certificates))
-	if len(certificateNames) > 0 {
-		require.Equal(tb, certificateNames, certNames)
-	}
+
+	assert.ElementsMatch(tb, certificateNames, certNames)
 
 	// verify that only the artifacts and the signatures are in the dist dir
 	gotFiles := []string{}