Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 23 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
29 changes: 19 additions & 10 deletions internal/bifrost/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,25 @@ const (
DefaultRetryAttempts = 3
DefaultRetryDelay = 2 * time.Second
userAgentProduct = "bifrost-cli"
metadataQueryPrefix = "metadata."
)

type API interface {
UploadSBOMFile(ctx context.Context, service string, serviceVersion string, filePath string) error
}

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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions internal/bifrost/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
57 changes: 57 additions & 0 deletions internal/bifrost/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
9 changes: 9 additions & 0 deletions internal/bifrost/custom_metadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Copyright 2026 bifrost security
// SPDX-License-Identifier: Apache-2.0

package bifrost

type CustomMetadataEntry struct {
Key string
Value string
}
15 changes: 15 additions & 0 deletions internal/bifrost/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/url"
"os"
"strconv"
"strings"
"time"
)

Expand All @@ -27,6 +28,7 @@ type Options struct {
gitOrigin string
gitRepoPath string
gitAutoDetect bool
customMetadata []CustomMetadataEntry
}

func RegisterOptions(fl *flag.FlagSet, opts *Options) {
Expand All @@ -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 {
Expand Down
19 changes: 10 additions & 9 deletions internal/bifrost/sbom_upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading