Skip to content
Merged
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
35 changes: 23 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,10 @@ To use the CLI, you first need a bifrost account and an API token.
make build
```

4. Upload an SBOM for a service and version:
4. Upload an SBOM for a service, version, and image:

```bash
BIFROST_API_KEY=my-key ./bifrost --service=name --service-version=34ha353 sbom upload /path/to/sbom.json
BIFROST_API_KEY=my-key ./bifrost --service=name --service-version=34ha353 --image=registry.example.com/team/app:34ha353 sbom upload /path/to/sbom.json
```
Comment thread
alexanderbsingh marked this conversation as resolved.

The API token is sent as a bearer token when the CLI uploads the SBOM.
Expand All @@ -91,31 +91,31 @@ The API token is sent as a bearer token when the CLI uploads the SBOM.
The CLI uploads one or more SBOM files and associates them with a bifrost service and service version.

```bash
./bifrost --service=my-service --service-version=1.2.3 sbom upload /path/to/sbom.json
./bifrost --service=my-service --service-version=1.2.3 --image=registry.example.com/team/app:1.2.3 sbom upload /path/to/sbom.json
```
Comment thread
alexanderbsingh marked this conversation as resolved.

You can also read an SBOM from standard input by using `-` as the path:

```bash
cat /path/to/sbom.json | ./bifrost --service=my-service --service-version=1.2.3 sbom upload -
cat /path/to/sbom.json | ./bifrost --service=my-service --service-version=1.2.3 --image=registry.example.com/team/app:1.2.3 sbom upload -
```

You can control retry behavior for transient upload failures:

```bash
./bifrost --service=my-service --service-version=1.2.3 --retry-attempts=5 --retry-delay=5s sbom upload /path/to/sbom.json
./bifrost --service=my-service --service-version=1.2.3 --image=registry.example.com/team/app:1.2.3 --retry-attempts=5 --retry-delay=5s sbom upload /path/to/sbom.json
```

You can attach Git metadata to the upload request:

```bash
./bifrost --service=my-service --service-version=1.2.3 --git-branch=main --git-commit-sha=abc123 --git-origin=https://github.com/example/project.git sbom upload /path/to/sbom.json
./bifrost --service=my-service --service-version=1.2.3 --image=registry.example.com/team/app:1.2.3 --git-branch=main --git-commit-sha=abc123 --git-origin=https://github.com/example/project.git sbom upload /path/to/sbom.json
```

Example with Trivy generating a CycloneDX SBOM for a container image and piping it directly to bifrost:

```bash
trivy image --format cyclonedx <image> | ./bifrost --service=my-service --service-version=1.2.3 sbom upload -
trivy image --format cyclonedx <image> | ./bifrost --service=my-service --service-version=1.2.3 --image=<image> sbom upload -
```

Example with GitHub CLI exporting the repository dependency graph SBOM and piping the SPDX document to bifrost:
Expand All @@ -125,13 +125,24 @@ gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2026-03-10" \
/repos/OWNER/REPO/dependency-graph/sbom \
--jq '.sbom' | ./bifrost --service=my-service --service-version=1.2.3 sbom upload -
--jq '.sbom' | ./bifrost --service=my-service --service-version=1.2.3 --image=ghcr.io/OWNER/REPO:1.2.3 sbom upload -
```

You can provide the API token through:

- The `BIFROST_API_KEY` environment variable
- The `--api-key` flag
## 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. |
| `--help` | No | | Show help and exit. |

## Useful Links

Expand Down
95 changes: 52 additions & 43 deletions internal/bifrost/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,41 +25,45 @@ 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
}

type api struct {
client http.Client
serverUrl string
token string
retryAttempts int
retryDelay time.Duration
retryOutput io.Writer
gitBranch string
gitCommitSHA string
gitOrigin string
userAgent string
client http.Client
cfg APIConfig
}

func NewAPI(serverURL string, token string, retryAttempts int, retryDelay time.Duration, gitBranch string, gitCommitSHA string, gitOrigin string, version string) API {
if retryAttempts < 0 {
retryAttempts = 0
func NewAPI(cfg APIConfig) API {
if cfg.RetryAttempts < 0 {
cfg.RetryAttempts = 0
}
if retryDelay < 0 {
retryDelay = 0
if cfg.RetryDelay < 0 {
cfg.RetryDelay = 0
}
if cfg.RetryOutput == nil {
cfg.RetryOutput = os.Stderr
}
return &api{
client: http.Client{},
serverUrl: serverURL,
token: token,
retryAttempts: retryAttempts,
retryDelay: retryDelay,
retryOutput: os.Stderr,
gitBranch: gitBranch,
gitCommitSHA: gitCommitSHA,
gitOrigin: gitOrigin,
userAgent: userAgent(version),
client: http.Client{},
cfg: cfg,
}
}

func (a *api) UploadSBOMFile(ctx context.Context, service string, serviceVersion string, filePath string) error {
if serviceVersion == "" && a.cfg.Image == "" {
return fmt.Errorf("either service version or image is required")
}

fi, err := os.Stat(filePath)
if err != nil {
if os.IsNotExist(err) {
Expand All @@ -82,16 +86,16 @@ func (a *api) UploadSBOMFile(ctx context.Context, service string, serviceVersion

func (a *api) uploadSBOM(ctx context.Context, service string, serviceVersion string, sourceLabel string, openBody func() (io.ReadCloser, error)) error {
var err error
for attempt := 0; attempt <= a.retryAttempts; attempt++ {
for attempt := 0; attempt <= a.cfg.RetryAttempts; attempt++ {
err = a.uploadSBOMOnce(ctx, service, serviceVersion, sourceLabel, openBody)
if err == nil {
return nil
}
if attempt == a.retryAttempts || !shouldRetry(err) {
if attempt == a.cfg.RetryAttempts || !shouldRetry(err) {
return err
}
a.printRetryMessage(sourceLabel, err, attempt+1)
if err := sleepWithContext(ctx, a.retryDelay); err != nil {
if err := sleepWithContext(ctx, a.cfg.RetryDelay); err != nil {
return err
}
}
Expand All @@ -100,17 +104,17 @@ func (a *api) uploadSBOM(ctx context.Context, service string, serviceVersion str
}

func (a *api) printRetryMessage(sourceLabel string, err error, retryNumber int) {
if a.retryOutput == nil {
if a.cfg.RetryOutput == nil {
return
}
_, _ = fmt.Fprintf(
a.retryOutput,
a.cfg.RetryOutput,
"Upload failed for %s: %v. Retrying in %s (%d/%d)\n",
sourceLabel,
err,
a.retryDelay,
a.cfg.RetryDelay,
retryNumber,
a.retryAttempts,
a.cfg.RetryAttempts,
)
}

Expand All @@ -127,10 +131,9 @@ func (a *api) uploadSBOMOnce(ctx context.Context, service string, serviceVersion
ctx,
http.MethodPost,
fmt.Sprintf(
"%s/api/v2/service/%s/version/%s/sbom",
a.serverUrl,
"%s/api/v2/service/%s/version/sbom",
a.cfg.ServerURL,
url.PathEscape(service),
url.PathEscape(serviceVersion),
),
file,
)
Expand All @@ -139,20 +142,26 @@ func (a *api) uploadSBOMOnce(ctx context.Context, service string, serviceVersion
}

query := req.URL.Query()
if a.gitBranch != "" {
query.Set("git_branch", a.gitBranch)
if serviceVersion != "" {
query.Set("version", serviceVersion)
}
if a.cfg.Image != "" {
query.Set("image", a.cfg.Image)
}
if a.cfg.GitBranch != "" {
query.Set("git_branch", a.cfg.GitBranch)
}
if a.gitCommitSHA != "" {
query.Set("git_commit_sha", a.gitCommitSHA)
if a.cfg.GitCommitSHA != "" {
query.Set("git_commit_sha", a.cfg.GitCommitSHA)
}
if a.gitOrigin != "" {
query.Set("git_origin", a.gitOrigin)
if a.cfg.GitOrigin != "" {
query.Set("git_origin", a.cfg.GitOrigin)
}
req.URL.RawQuery = query.Encode()

req.Header.Add("Authorization", "Bearer "+a.token)
req.Header.Add("Authorization", "Bearer "+a.cfg.Token)
req.Header.Add("Content-Type", "application/json")
req.Header.Set("User-Agent", a.userAgent)
req.Header.Set("User-Agent", userAgent(a.cfg.CliVersion))

resp, err := a.client.Do(req)
if err != nil {
Expand Down
Loading
Loading