From c28a34ed97d66c47f20524e4709807f871c0ee82 Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Wed, 8 Jul 2026 17:06:56 +0200 Subject: [PATCH 1/6] feat: add support for custom metadata in SBOM uploads --- README.md | 41 ++++++---- internal/bifrost/api.go | 33 +++++--- internal/bifrost/api_test.go | 38 +++++++++ internal/bifrost/cli_test.go | 115 ++++++++++++++++++++++++++++ internal/bifrost/custom_metadata.go | 33 ++++++++ internal/bifrost/options.go | 2 + internal/bifrost/sbom_upload.go | 19 ++--- 7 files changed, 246 insertions(+), 35 deletions(-) create mode 100644 internal/bifrost/custom_metadata.go diff --git a/README.md b/README.md index 21ca167..85bb777 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ your build pipeline. bifrost helps teams understand and reduce real workload risk with runtime security for containerized applications. Learn more: + - Website: [bifrostsec.com](https://bifrostsec.com/) - Documentation: [docs.bifrostsec.com](https://docs.bifrostsec.com/) - Portal: [portal.bifrostsec.com](https://portal.bifrostsec.com/) @@ -135,6 +136,12 @@ You can enable automatic Git metadata detection with: - The `BIFROST_GIT_AUTO_DETECT=true` environment variable - The `--git-auto-detect` flag +You can attach custom metadata with repeated `--metadata key=value` options: + +```bash +./bifrost --service=my-service --service-version=1.2.3 --metadata github.workflow="$GITHUB_WORKFLOW" --metadata github.run_id="$GITHUB_RUN_ID" sbom upload /path/to/sbom.json +``` + Example with Trivy generating a CycloneDX SBOM for a container image and piping it directly to bifrost: ```bash @@ -153,27 +160,29 @@ gh api \ ## Options -| Option | Required | Environment variable(s) | Description | -|------------------------------|-------------|------------------------------------|---------------------------------------------------------------------------------------------------| -| `--api-key` | Yes | `BIFROST_API_KEY` | Bifrost API key used for authentication. | -| `--service` | Yes | `SERVICE` | Name of the service. | -| `--service-version` | Conditional | `SERVICE_VERSION` | Service version for the uploaded SBOM. Required unless an image is provided. | -| `--image` | Conditional | `IMAGE` | Container image reference for the uploaded SBOM. Required unless a service version is provided. | -| `--server-url` | No | `SERVER_URL`, `BIFROST_SERVER_URL` | URL to the bifrost server. | -| `--retry-attempts` | No | | Number of retry attempts for transient upload failures. | -| `--retry-delay` | No | | Delay between upload retry attempts. | -| `--git-branch` | No | | Git branch name to attach to the upload. | -| `--git-commit-sha` | No | | Git commit SHA to attach to the upload. | -| `--git-origin` | No | | Git origin URL to attach to the upload. | -| `--git-repo-path` | No | | Git repository path used for automatic Git metadata detection. Defaults to the current directory. | -| `--git-auto-detect` | No | `BIFROST_GIT_AUTO_DETECT` | Automatically fill missing Git metadata from the current Git repository when available. | -| `--help` | No | | Show help and exit. | +| Option | Required | Environment variable(s) | Description | +|---------------------|-------------|------------------------------------|---------------------------------------------------------------------------------------------------| +| `--api-key` | Yes | `BIFROST_API_KEY` | Bifrost API key used for authentication. | +| `--service` | Yes | `SERVICE` | Name of the service. | +| `--service-version` | Conditional | `SERVICE_VERSION` | Service version for the uploaded SBOM. Required unless an image is provided. | +| `--image` | Conditional | `IMAGE` | Container image reference for the uploaded SBOM. Required unless a service version is provided. | +| `--server-url` | No | `SERVER_URL`, `BIFROST_SERVER_URL` | URL to the bifrost server. | +| `--retry-attempts` | No | | Number of retry attempts for transient upload failures. | +| `--retry-delay` | No | | Delay between upload retry attempts. | +| `--git-branch` | No | | Git branch name to attach to the upload. | +| `--git-commit-sha` | No | | Git commit SHA to attach to the upload. | +| `--git-origin` | No | | Git origin URL to attach to the upload. | +| `--git-repo-path` | No | | Git repository path used for automatic Git metadata detection. Defaults to the current directory. | +| `--git-auto-detect` | No | `BIFROST_GIT_AUTO_DETECT` | Automatically fill missing Git metadata from the current Git repository when available. | +| `--metadata` | No | | Custom metadata in `key=value` format. Can be provided multiple times. | +| `--help` | No | | Show help and exit. | ## Useful Links - Website: [bifrostsec.com](https://bifrostsec.com/) - Documentation: [docs.bifrostsec.com](https://docs.bifrostsec.com/) -- Releases: [github.com/bifrostsec/bifrost-cli/releases/latest](https://github.com/bifrostsec/bifrost-cli/releases/latest) +- +Releases: [github.com/bifrostsec/bifrost-cli/releases/latest](https://github.com/bifrostsec/bifrost-cli/releases/latest) - Getting started guide: [docs.bifrostsec.com/guides/get-started](https://docs.bifrostsec.com/guides/get-started/) - SBOM reference: [https://docs.bifrostsec.com/reference/sbom/](https://docs.bifrostsec.com/reference/sbom/) - API reference: [docs.bifrostsec.com/api/v2](https://docs.bifrostsec.com/api/v2/) diff --git a/internal/bifrost/api.go b/internal/bifrost/api.go index 0510b19..6b2b349 100644 --- a/internal/bifrost/api.go +++ b/internal/bifrost/api.go @@ -19,6 +19,7 @@ const ( DefaultRetryAttempts = 3 DefaultRetryDelay = 2 * time.Second userAgentProduct = "bifrost-cli" + metadataQueryPrefix = "metadata." ) type API interface { @@ -26,16 +27,17 @@ type API interface { } type APIConfig struct { - ServerURL string - Token string - RetryAttempts int - RetryDelay time.Duration - RetryOutput io.Writer - GitBranch string - GitCommitSHA string - GitOrigin string - Image string - CliVersion string + ServerURL string + Token string + RetryAttempts int + RetryDelay time.Duration + RetryOutput io.Writer + GitBranch string + GitCommitSHA string + GitOrigin string + Image string + CliVersion string + CustomMetadata CustomMetadata } type api struct { @@ -157,6 +159,7 @@ func (a *api) uploadSBOMOnce(ctx context.Context, service string, serviceVersion if a.cfg.GitOrigin != "" { query.Set("git_origin", a.cfg.GitOrigin) } + addMetadataQueryParams(query, a.cfg.CustomMetadata) req.URL.RawQuery = query.Encode() req.Header.Add("Authorization", "Bearer "+a.cfg.Token) @@ -190,6 +193,16 @@ func userAgent(version string) string { return fmt.Sprintf("%s/%s", userAgentProduct, version) } +func addMetadataQueryParams(query url.Values, metadata CustomMetadata) { + for key, value := range metadata { + query.Set(metadataQueryParamName(key), value) + } +} + +func metadataQueryParamName(metadataName string) string { + return metadataQueryPrefix + metadataName +} + type uploadError struct { sourceLabel string statusCode int diff --git a/internal/bifrost/api_test.go b/internal/bifrost/api_test.go index 9fc3e56..e56ccc3 100644 --- a/internal/bifrost/api_test.go +++ b/internal/bifrost/api_test.go @@ -170,6 +170,44 @@ func TestAPI_UploadSBOM_IncludesImageQueryParam(t *testing.T) { assert.NoError(t, err) } +func TestAPI_UploadSBOM_IncludesMetadataQueryParams(t *testing.T) { + expectedQuery := url.Values{} + expectedQuery.Set("version", "test-version") + expectedQuery.Set("metadata.github.workflow", "build-and-scan") + expectedQuery.Set("metadata.github.run_id", "123456") + expectedQuery.Set("metadata.authorization", "metadata-value") + + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + assert.Equal(t, expectedQuery.Encode(), r.URL.RawQuery) + assert.Equal(t, "build-and-scan", r.URL.Query().Get("metadata.github.workflow")) + assert.Equal(t, "123456", r.URL.Query().Get("metadata.github.run_id")) + assert.Equal(t, "metadata-value", r.URL.Query().Get("metadata.authorization")) + w.WriteHeader(http.StatusOK) + })) + defer httpServer.Close() + + path := "test-sbom.json" + err := os.WriteFile(path, []byte(`{"name":"test","version":"1.0"}`), 0644) + assert.NoError(t, err) + defer func() { + _ = os.Remove(path) + }() + + cfg := newTestAPIConfig(httpServer.URL) + cfg.CustomMetadata = CustomMetadata{ + "github.workflow": "build-and-scan", + "github.run_id": "123456", + "authorization": "metadata-value", + } + + api := NewAPI(cfg) + + err = api.UploadSBOMFile(context.Background(), "test-service", "test-version", path) + assert.NoError(t, err) +} + func TestAPI_UploadSBOM_RequiresVersionOrImage(t *testing.T) { httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("request should not be sent when both version and image are missing") diff --git a/internal/bifrost/cli_test.go b/internal/bifrost/cli_test.go index 5063c37..922ab64 100644 --- a/internal/bifrost/cli_test.go +++ b/internal/bifrost/cli_test.go @@ -185,6 +185,35 @@ func TestCLI_ValidCommandInGitRepoWithoutGitAutoDetectOmitsGitMetadata(t *testin assert.Equal(t, 0, exitCode) } +func TestCLI_ValidCommandWithMetadata(t *testing.T) { + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "build-and-scan", r.URL.Query().Get("metadata.github.workflow")) + assert.Equal(t, "123456", r.URL.Query().Get("metadata.github.run_id")) + w.WriteHeader(http.StatusOK) + })) + defer httpServer.Close() + + path := "test-sbom.json" + err := os.WriteFile(path, []byte(`{"name":"test","version":"1.0"}`), 0644) + assert.NoError(t, err) + defer func() { + _ = os.Remove(path) + }() + + args := []string{ + fmt.Sprintf("--server-url=%s", httpServer.URL), + "--service=test-service", + "--service-version=1.0", + "--api-key=test-token", + "--metadata=github.workflow=build-and-scan", + "--metadata=github.run_id=123456", + "sbom", "upload", path, + } + + exitCode := CLI("1.0", "commit", args) + assert.Equal(t, 0, exitCode) +} + func TestCLI_ExplicitGitAutoDetectFlagOverridesEnvironment(t *testing.T) { t.Setenv("BIFROST_GIT_AUTO_DETECT", "true") branch := "feature/flag-overrides-env" @@ -677,3 +706,89 @@ func TestCLI_InvalidRetryDelay(t *testing.T) { exitCode := CLI("1.0", "commit", args) assert.Equal(t, 2, exitCode) } + +func TestCLI_InvalidMetadataFormat(t *testing.T) { + args := []string{ + "--server-url=https://portal.bifrostsec.com", + "--service=test-service", + "--service-version=1.0", + "--api-key=test-token", + "--metadata=github.workflow", + "sbom", "upload", "test-sbom.json", + } + + exitCode := CLI("1.0", "commit", args) + assert.Equal(t, 2, exitCode) +} + +func TestCLI_InvalidMetadataKey(t *testing.T) { + args := []string{ + "--server-url=https://portal.bifrostsec.com", + "--service=test-service", + "--service-version=1.0", + "--api-key=test-token", + "--metadata==build", + "sbom", "upload", "test-sbom.json", + } + + exitCode := CLI("1.0", "commit", args) + assert.Equal(t, 2, exitCode) +} + +func TestCLI_ValidCommandWithSpecialCharacterMetadata(t *testing.T) { + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "build scan", r.URL.Query().Get("metadata.github workflow")) + assert.Equal(t, "feature/deployments & scans", r.URL.Query().Get("metadata.github.ref")) + w.WriteHeader(http.StatusOK) + })) + defer httpServer.Close() + + path := "test-sbom.json" + err := os.WriteFile(path, []byte(`{"name":"test","version":"1.0"}`), 0644) + assert.NoError(t, err) + defer func() { + _ = os.Remove(path) + }() + + args := []string{ + fmt.Sprintf("--server-url=%s", httpServer.URL), + "--service=test-service", + "--service-version=1.0", + "--api-key=test-token", + "--metadata=github workflow=build scan", + "--metadata=github.ref=feature/deployments & scans", + "sbom", "upload", path, + } + + exitCode := CLI("1.0", "commit", args) + assert.Equal(t, 0, exitCode) +} + +func TestCLI_ValidCommandWithSimilarMetadataKeys(t *testing.T) { + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "build", r.URL.Query().Get("metadata.github.workflow")) + assert.Equal(t, "scan", r.URL.Query().Get("metadata.github_workflow")) + w.WriteHeader(http.StatusOK) + })) + defer httpServer.Close() + + path := "test-sbom.json" + err := os.WriteFile(path, []byte(`{"name":"test","version":"1.0"}`), 0644) + assert.NoError(t, err) + defer func() { + _ = os.Remove(path) + }() + + args := []string{ + fmt.Sprintf("--server-url=%s", httpServer.URL), + "--service=test-service", + "--service-version=1.0", + "--api-key=test-token", + "--metadata=github.workflow=build", + "--metadata=github_workflow=scan", + "sbom", "upload", path, + } + + exitCode := CLI("1.0", "commit", args) + assert.Equal(t, 0, exitCode) +} diff --git a/internal/bifrost/custom_metadata.go b/internal/bifrost/custom_metadata.go new file mode 100644 index 0000000..6a495c6 --- /dev/null +++ b/internal/bifrost/custom_metadata.go @@ -0,0 +1,33 @@ +// Copyright 2026 bifrost security +// SPDX-License-Identifier: Apache-2.0 + +package bifrost + +import ( + "fmt" + "strings" +) + +// CustomMetadata is user-provided metadata supplied through repeated --metadata flags. +type CustomMetadata map[string]string + +// Set implements flag.Value by parsing key=value metadata input. +func (m *CustomMetadata) Set(value string) error { + metadataName, metadataValue, ok := strings.Cut(value, "=") + if !ok { + return fmt.Errorf("metadata must be in key=value format") + } + if metadataName == "" { + return fmt.Errorf("metadata key is required") + } + if *m == nil { + *m = CustomMetadata{} + } + (*m)[metadataName] = metadataValue + return nil +} + +// String implements flag.Value. Metadata has no default display value. +func (m *CustomMetadata) String() string { + return "" +} diff --git a/internal/bifrost/options.go b/internal/bifrost/options.go index 102d1a8..bec4e8b 100644 --- a/internal/bifrost/options.go +++ b/internal/bifrost/options.go @@ -27,6 +27,7 @@ type Options struct { gitOrigin string gitRepoPath string gitAutoDetect bool + customMetadata CustomMetadata } func RegisterOptions(fl *flag.FlagSet, opts *Options) { @@ -42,6 +43,7 @@ func RegisterOptions(fl *flag.FlagSet, opts *Options) { fl.StringVar(&opts.gitOrigin, "git-origin", "", "Optional Git origin URL for the uploaded SBOM") fl.StringVar(&opts.gitRepoPath, "git-repo-path", ".", "Git repository path used for automatic Git metadata detection") fl.BoolVar(&opts.gitAutoDetect, gitAutoDetectFlag, false, "Automatically detect Git metadata (or environment variable BIFROST_GIT_AUTO_DETECT=true)") + fl.Var(&opts.customMetadata, "metadata", "Optional custom metadata in key=value format; can be provided multiple times") } func ValidateBaseOptions(fl *flag.FlagSet, opts *Options) error { diff --git a/internal/bifrost/sbom_upload.go b/internal/bifrost/sbom_upload.go index 3223951..9ca952b 100644 --- a/internal/bifrost/sbom_upload.go +++ b/internal/bifrost/sbom_upload.go @@ -58,15 +58,16 @@ func (t sbomUploadTask) Run(ctx context.Context) error { return fmt.Errorf("no SBOM file paths provided") } api := NewAPI(APIConfig{ - ServerURL: t.ServerURL, - Token: t.apiKey, - RetryAttempts: t.retryAttempts, - RetryDelay: t.retryDelay, - GitBranch: t.gitBranch, - GitCommitSHA: t.gitCommitSHA, - GitOrigin: t.gitOrigin, - Image: t.image, - CliVersion: t.cliVersion, + ServerURL: t.ServerURL, + Token: t.apiKey, + RetryAttempts: t.retryAttempts, + RetryDelay: t.retryDelay, + GitBranch: t.gitBranch, + GitCommitSHA: t.gitCommitSHA, + GitOrigin: t.gitOrigin, + Image: t.image, + CliVersion: t.cliVersion, + CustomMetadata: t.customMetadata, }) stdinConsumed := false for _, path := range t.paths { From a60a804d324c86d3fddee7ab672106c17b5fe69d Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Thu, 9 Jul 2026 14:37:25 +0200 Subject: [PATCH 2/6] refactor: replace map-based custom metadata with structured entries --- internal/bifrost/api.go | 8 ++++---- internal/bifrost/api_test.go | 24 ++++++++++------------- internal/bifrost/cli_test.go | 28 +++++++++++++++++++++++++++ internal/bifrost/custom_metadata.go | 30 +++-------------------------- internal/bifrost/options.go | 18 ++++++++++++++--- 5 files changed, 60 insertions(+), 48 deletions(-) diff --git a/internal/bifrost/api.go b/internal/bifrost/api.go index 6b2b349..0ba146b 100644 --- a/internal/bifrost/api.go +++ b/internal/bifrost/api.go @@ -37,7 +37,7 @@ type APIConfig struct { GitOrigin string Image string CliVersion string - CustomMetadata CustomMetadata + CustomMetadata []CustomMetadataEntry } type api struct { @@ -193,9 +193,9 @@ func userAgent(version string) string { return fmt.Sprintf("%s/%s", userAgentProduct, version) } -func addMetadataQueryParams(query url.Values, metadata CustomMetadata) { - for key, value := range metadata { - query.Set(metadataQueryParamName(key), value) +func addMetadataQueryParams(query url.Values, metadata []CustomMetadataEntry) { + for _, entry := range metadata { + query.Add(metadataQueryParamName(entry.Key), entry.Value) } } diff --git a/internal/bifrost/api_test.go b/internal/bifrost/api_test.go index e56ccc3..4826bd1 100644 --- a/internal/bifrost/api_test.go +++ b/internal/bifrost/api_test.go @@ -171,19 +171,13 @@ func TestAPI_UploadSBOM_IncludesImageQueryParam(t *testing.T) { } func TestAPI_UploadSBOM_IncludesMetadataQueryParams(t *testing.T) { - expectedQuery := url.Values{} - expectedQuery.Set("version", "test-version") - expectedQuery.Set("metadata.github.workflow", "build-and-scan") - expectedQuery.Set("metadata.github.run_id", "123456") - expectedQuery.Set("metadata.authorization", "metadata-value") - httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) assert.Equal(t, "application/json", r.Header.Get("Content-Type")) - assert.Equal(t, expectedQuery.Encode(), r.URL.RawQuery) - assert.Equal(t, "build-and-scan", r.URL.Query().Get("metadata.github.workflow")) - assert.Equal(t, "123456", r.URL.Query().Get("metadata.github.run_id")) - assert.Equal(t, "metadata-value", r.URL.Query().Get("metadata.authorization")) + assert.Equal(t, []string{"build-and-scan"}, r.URL.Query()["metadata.github.workflow"]) + assert.Equal(t, []string{"123456"}, r.URL.Query()["metadata.github.run_id"]) + assert.Equal(t, []string{"metadata-value"}, r.URL.Query()["metadata.authorization"]) + assert.ElementsMatch(t, []string{"unit", "integration"}, r.URL.Query()["metadata.test.suite"]) w.WriteHeader(http.StatusOK) })) defer httpServer.Close() @@ -196,10 +190,12 @@ func TestAPI_UploadSBOM_IncludesMetadataQueryParams(t *testing.T) { }() cfg := newTestAPIConfig(httpServer.URL) - cfg.CustomMetadata = CustomMetadata{ - "github.workflow": "build-and-scan", - "github.run_id": "123456", - "authorization": "metadata-value", + cfg.CustomMetadata = []CustomMetadataEntry{ + {Key: "github.workflow", Value: "build-and-scan"}, + {Key: "github.run_id", Value: "123456"}, + {Key: "authorization", Value: "metadata-value"}, + {Key: "test.suite", Value: "unit"}, + {Key: "test.suite", Value: "integration"}, } api := NewAPI(cfg) diff --git a/internal/bifrost/cli_test.go b/internal/bifrost/cli_test.go index 922ab64..55a9daf 100644 --- a/internal/bifrost/cli_test.go +++ b/internal/bifrost/cli_test.go @@ -792,3 +792,31 @@ func TestCLI_ValidCommandWithSimilarMetadataKeys(t *testing.T) { exitCode := CLI("1.0", "commit", args) assert.Equal(t, 0, exitCode) } + +func TestCLI_ValidCommandWithRepeatedMetadataKey(t *testing.T) { + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.ElementsMatch(t, []string{"unit", "integration"}, r.URL.Query()["metadata.test.suite"]) + w.WriteHeader(http.StatusOK) + })) + defer httpServer.Close() + + path := "test-sbom.json" + err := os.WriteFile(path, []byte(`{"name":"test","version":"1.0"}`), 0644) + assert.NoError(t, err) + defer func() { + _ = os.Remove(path) + }() + + args := []string{ + fmt.Sprintf("--server-url=%s", httpServer.URL), + "--service=test-service", + "--service-version=1.0", + "--api-key=test-token", + "--metadata=test.suite=unit", + "--metadata=test.suite=integration", + "sbom", "upload", path, + } + + exitCode := CLI("1.0", "commit", args) + assert.Equal(t, 0, exitCode) +} diff --git a/internal/bifrost/custom_metadata.go b/internal/bifrost/custom_metadata.go index 6a495c6..02a6fa0 100644 --- a/internal/bifrost/custom_metadata.go +++ b/internal/bifrost/custom_metadata.go @@ -3,31 +3,7 @@ package bifrost -import ( - "fmt" - "strings" -) - -// CustomMetadata is user-provided metadata supplied through repeated --metadata flags. -type CustomMetadata map[string]string - -// Set implements flag.Value by parsing key=value metadata input. -func (m *CustomMetadata) Set(value string) error { - metadataName, metadataValue, ok := strings.Cut(value, "=") - if !ok { - return fmt.Errorf("metadata must be in key=value format") - } - if metadataName == "" { - return fmt.Errorf("metadata key is required") - } - if *m == nil { - *m = CustomMetadata{} - } - (*m)[metadataName] = metadataValue - return nil -} - -// String implements flag.Value. Metadata has no default display value. -func (m *CustomMetadata) String() string { - return "" +type CustomMetadataEntry struct { + Key string + Value string } diff --git a/internal/bifrost/options.go b/internal/bifrost/options.go index bec4e8b..5a8ab16 100644 --- a/internal/bifrost/options.go +++ b/internal/bifrost/options.go @@ -8,7 +8,7 @@ import ( "fmt" "net/url" "os" - "strconv" + "strings" "time" ) @@ -27,7 +27,7 @@ type Options struct { gitOrigin string gitRepoPath string gitAutoDetect bool - customMetadata CustomMetadata + customMetadata []CustomMetadataEntry } func RegisterOptions(fl *flag.FlagSet, opts *Options) { @@ -43,7 +43,19 @@ func RegisterOptions(fl *flag.FlagSet, opts *Options) { fl.StringVar(&opts.gitOrigin, "git-origin", "", "Optional Git origin URL for the uploaded SBOM") fl.StringVar(&opts.gitRepoPath, "git-repo-path", ".", "Git repository path used for automatic Git metadata detection") fl.BoolVar(&opts.gitAutoDetect, gitAutoDetectFlag, false, "Automatically detect Git metadata (or environment variable BIFROST_GIT_AUTO_DETECT=true)") - fl.Var(&opts.customMetadata, "metadata", "Optional custom metadata in key=value format; can be provided multiple times") + fl.Func("metadata", "Optional custom metadata in key=value format; can be provided multiple times", opts.addCustomMetadata) +} + +func (opts *Options) addCustomMetadata(value string) error { + key, val, ok := strings.Cut(value, "=") + if !ok { + return fmt.Errorf("metadata must be in key=value format") + } + if key == "" { + return fmt.Errorf("metadata key is required") + } + opts.customMetadata = append(opts.customMetadata, CustomMetadataEntry{Key: key, Value: val}) + return nil } func ValidateBaseOptions(fl *flag.FlagSet, opts *Options) error { From 5a06b39b507504e883ad0a20f8962fd733a7b91a Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Thu, 9 Jul 2026 14:44:48 +0200 Subject: [PATCH 3/6] chore: add strconv import to options file --- internal/bifrost/options.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/bifrost/options.go b/internal/bifrost/options.go index 5a8ab16..faf9cee 100644 --- a/internal/bifrost/options.go +++ b/internal/bifrost/options.go @@ -8,6 +8,7 @@ import ( "fmt" "net/url" "os" + "strconv" "strings" "time" ) From 8d2dbba75a4c5a7a1b75d7ad80c9bd6f8e068237 Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Thu, 9 Jul 2026 15:05:57 +0200 Subject: [PATCH 4/6] chore: fix markdown formatting for releases link in README.md --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 85bb777..3749f4b 100644 --- a/README.md +++ b/README.md @@ -181,8 +181,7 @@ gh api \ - Website: [bifrostsec.com](https://bifrostsec.com/) - Documentation: [docs.bifrostsec.com](https://docs.bifrostsec.com/) -- -Releases: [github.com/bifrostsec/bifrost-cli/releases/latest](https://github.com/bifrostsec/bifrost-cli/releases/latest) +- Releases: [github.com/bifrostsec/bifrost-cli/releases/latest](https://github.com/bifrostsec/bifrost-cli/releases/latest) - Getting started guide: [docs.bifrostsec.com/guides/get-started](https://docs.bifrostsec.com/guides/get-started/) - SBOM reference: [https://docs.bifrostsec.com/reference/sbom/](https://docs.bifrostsec.com/reference/sbom/) - API reference: [docs.bifrostsec.com/api/v2](https://docs.bifrostsec.com/api/v2/) From c04477683354542d2a42d434fb3afd17bd00d647 Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Thu, 9 Jul 2026 15:13:09 +0200 Subject: [PATCH 5/6] refactor: simplify metadata query param generation by inlining function logic --- internal/bifrost/api.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/internal/bifrost/api.go b/internal/bifrost/api.go index 0ba146b..41e2b80 100644 --- a/internal/bifrost/api.go +++ b/internal/bifrost/api.go @@ -195,14 +195,10 @@ func userAgent(version string) string { func addMetadataQueryParams(query url.Values, metadata []CustomMetadataEntry) { for _, entry := range metadata { - query.Add(metadataQueryParamName(entry.Key), entry.Value) + query.Add(metadataQueryPrefix+entry.Key, entry.Value) } } -func metadataQueryParamName(metadataName string) string { - return metadataQueryPrefix + metadataName -} - type uploadError struct { sourceLabel string statusCode int From 3cc8fefeb4035958eaed8a416e725799e2673bf5 Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Thu, 9 Jul 2026 15:29:30 +0200 Subject: [PATCH 6/6] test: add additional metadata validation in API tests and remove redundant cli tests --- internal/bifrost/api_test.go | 6 +++ internal/bifrost/cli_test.go | 86 ------------------------------------ 2 files changed, 6 insertions(+), 86 deletions(-) diff --git a/internal/bifrost/api_test.go b/internal/bifrost/api_test.go index 4826bd1..62969f1 100644 --- a/internal/bifrost/api_test.go +++ b/internal/bifrost/api_test.go @@ -178,6 +178,9 @@ func TestAPI_UploadSBOM_IncludesMetadataQueryParams(t *testing.T) { assert.Equal(t, []string{"123456"}, r.URL.Query()["metadata.github.run_id"]) assert.Equal(t, []string{"metadata-value"}, r.URL.Query()["metadata.authorization"]) assert.ElementsMatch(t, []string{"unit", "integration"}, r.URL.Query()["metadata.test.suite"]) + assert.Equal(t, []string{"scan"}, r.URL.Query()["metadata.github_workflow"]) + assert.Equal(t, []string{"build scan"}, r.URL.Query()["metadata.github workflow"]) + assert.Equal(t, []string{"feature/deployments & scans"}, r.URL.Query()["metadata.github.ref"]) w.WriteHeader(http.StatusOK) })) defer httpServer.Close() @@ -196,6 +199,9 @@ func TestAPI_UploadSBOM_IncludesMetadataQueryParams(t *testing.T) { {Key: "authorization", Value: "metadata-value"}, {Key: "test.suite", Value: "unit"}, {Key: "test.suite", Value: "integration"}, + {Key: "github_workflow", Value: "scan"}, + {Key: "github workflow", Value: "build scan"}, + {Key: "github.ref", Value: "feature/deployments & scans"}, } api := NewAPI(cfg) diff --git a/internal/bifrost/cli_test.go b/internal/bifrost/cli_test.go index 55a9daf..fb5ddd2 100644 --- a/internal/bifrost/cli_test.go +++ b/internal/bifrost/cli_test.go @@ -734,89 +734,3 @@ func TestCLI_InvalidMetadataKey(t *testing.T) { exitCode := CLI("1.0", "commit", args) assert.Equal(t, 2, exitCode) } - -func TestCLI_ValidCommandWithSpecialCharacterMetadata(t *testing.T) { - httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "build scan", r.URL.Query().Get("metadata.github workflow")) - assert.Equal(t, "feature/deployments & scans", r.URL.Query().Get("metadata.github.ref")) - w.WriteHeader(http.StatusOK) - })) - defer httpServer.Close() - - path := "test-sbom.json" - err := os.WriteFile(path, []byte(`{"name":"test","version":"1.0"}`), 0644) - assert.NoError(t, err) - defer func() { - _ = os.Remove(path) - }() - - args := []string{ - fmt.Sprintf("--server-url=%s", httpServer.URL), - "--service=test-service", - "--service-version=1.0", - "--api-key=test-token", - "--metadata=github workflow=build scan", - "--metadata=github.ref=feature/deployments & scans", - "sbom", "upload", path, - } - - exitCode := CLI("1.0", "commit", args) - assert.Equal(t, 0, exitCode) -} - -func TestCLI_ValidCommandWithSimilarMetadataKeys(t *testing.T) { - httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "build", r.URL.Query().Get("metadata.github.workflow")) - assert.Equal(t, "scan", r.URL.Query().Get("metadata.github_workflow")) - w.WriteHeader(http.StatusOK) - })) - defer httpServer.Close() - - path := "test-sbom.json" - err := os.WriteFile(path, []byte(`{"name":"test","version":"1.0"}`), 0644) - assert.NoError(t, err) - defer func() { - _ = os.Remove(path) - }() - - args := []string{ - fmt.Sprintf("--server-url=%s", httpServer.URL), - "--service=test-service", - "--service-version=1.0", - "--api-key=test-token", - "--metadata=github.workflow=build", - "--metadata=github_workflow=scan", - "sbom", "upload", path, - } - - exitCode := CLI("1.0", "commit", args) - assert.Equal(t, 0, exitCode) -} - -func TestCLI_ValidCommandWithRepeatedMetadataKey(t *testing.T) { - httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.ElementsMatch(t, []string{"unit", "integration"}, r.URL.Query()["metadata.test.suite"]) - w.WriteHeader(http.StatusOK) - })) - defer httpServer.Close() - - path := "test-sbom.json" - err := os.WriteFile(path, []byte(`{"name":"test","version":"1.0"}`), 0644) - assert.NoError(t, err) - defer func() { - _ = os.Remove(path) - }() - - args := []string{ - fmt.Sprintf("--server-url=%s", httpServer.URL), - "--service=test-service", - "--service-version=1.0", - "--api-key=test-token", - "--metadata=test.suite=unit", - "--metadata=test.suite=integration", - "sbom", "upload", path, - } - - exitCode := CLI("1.0", "commit", args) - assert.Equal(t, 0, exitCode) -}