diff --git a/README.md b/README.md index 21ca167..3749f4b 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,21 +160,22 @@ 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 diff --git a/internal/bifrost/api.go b/internal/bifrost/api.go index 0510b19..41e2b80 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 []CustomMetadataEntry } 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,12 @@ func userAgent(version string) string { return fmt.Sprintf("%s/%s", userAgentProduct, version) } +func addMetadataQueryParams(query url.Values, metadata []CustomMetadataEntry) { + for _, entry := range metadata { + query.Add(metadataQueryPrefix+entry.Key, entry.Value) + } +} + type uploadError struct { sourceLabel string statusCode int diff --git a/internal/bifrost/api_test.go b/internal/bifrost/api_test.go index 9fc3e56..62969f1 100644 --- a/internal/bifrost/api_test.go +++ b/internal/bifrost/api_test.go @@ -170,6 +170,46 @@ func TestAPI_UploadSBOM_IncludesImageQueryParam(t *testing.T) { assert.NoError(t, err) } +func TestAPI_UploadSBOM_IncludesMetadataQueryParams(t *testing.T) { + 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, []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"]) + 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() + + 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 = []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"}, + {Key: "github_workflow", Value: "scan"}, + {Key: "github workflow", Value: "build scan"}, + {Key: "github.ref", Value: "feature/deployments & scans"}, + } + + 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..fb5ddd2 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,31 @@ 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) +} diff --git a/internal/bifrost/custom_metadata.go b/internal/bifrost/custom_metadata.go new file mode 100644 index 0000000..02a6fa0 --- /dev/null +++ b/internal/bifrost/custom_metadata.go @@ -0,0 +1,9 @@ +// Copyright 2026 bifrost security +// SPDX-License-Identifier: Apache-2.0 + +package bifrost + +type CustomMetadataEntry struct { + Key string + Value string +} diff --git a/internal/bifrost/options.go b/internal/bifrost/options.go index 102d1a8..faf9cee 100644 --- a/internal/bifrost/options.go +++ b/internal/bifrost/options.go @@ -9,6 +9,7 @@ import ( "net/url" "os" "strconv" + "strings" "time" ) @@ -27,6 +28,7 @@ type Options struct { gitOrigin string gitRepoPath string gitAutoDetect bool + customMetadata []CustomMetadataEntry } func RegisterOptions(fl *flag.FlagSet, opts *Options) { @@ -42,6 +44,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.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 { 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 {