diff --git a/README.md b/README.md index 3acc2a1..21ca167 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ A command-line tool for uploading SBOM (Software Bill of Materials) files to bifrost. -This repository contains the `bifrost-cli`, which lets you submit SBOMs for a specific service and version to your bifrost organization. It is intended for local automation and CI/CD workflows where you already produce SBOMs as part of your build pipeline. +This repository contains the `bifrost-cli`, which lets you submit SBOMs for a specific service and version to your +bifrost organization. It is intended for local automation and CI/CD workflows where you already produce SBOMs as part of +your build pipeline. ## What is bifrost? @@ -21,59 +23,62 @@ To use the CLI, you first need a bifrost account and an API token. 2. Create an API token for your organization in the organization settings. 3. Choose how you want to install the CLI. - ### Install with Homebrew (macOS and Linux): + ### Install with Homebrew (macOS and Linux): ```bash brew install bifrostsec/tap/bifrost-cli ``` - This installs the `bifrost` command from the [bifrostsec/homebrew-tap](https://github.com/bifrostsec/homebrew-tap) tap. To update later: + This installs the `bifrost` command from the [bifrostsec/homebrew-tap](https://github.com/bifrostsec/homebrew-tap) + tap. To update later: ```bash brew update brew upgrade bifrost-cli ``` - *(Windows is not covered by Homebrew — use one of the options below.)* + *(Windows is not covered by Homebrew — use one of the options below.)* + + ### Download the released executable: - ### Download the released executable: - ```bash # Example for macOS on Apple Silicon curl -L -o bifrost https://github.com/bifrostsec/bifrost-cli/releases/latest/download/bifrost-darwin-arm64 chmod +x ./bifrost ``` - - *macOS note: the current macOS release binaries are not signed with an Apple Developer certificate. When you first run `./bifrost`, macOS may block it with a warning such as:* - - > **“bifrost” Not Opened** - > Apple could not verify “bifrost” is free of malware that may harm your Mac or compromise your privacy - - To allow the binary to run on macOS: - + + *macOS note: the current macOS release binaries are not signed with an Apple Developer certificate. When you first + run `./bifrost`, macOS may block it with a warning such as:* + + > **“bifrost” Not Opened** + > Apple could not verify “bifrost” is free of malware that may harm your Mac or compromise your privacy + + To allow the binary to run on macOS: + 1. Try to run `./bifrost` once so macOS registers the blocked executable. 2. Open `System Settings` > `Privacy & Security`. 3. Scroll down to the `Security` section and click `Allow Anyway` for `bifrost`. 4. Confirm with your login password if prompted. 5. Run `./bifrost` again. - - *The `Allow Anyway` button is only shown for a limited time after the blocked launch attempt, so if you do not see it, run `./bifrost` again and return to `Privacy & Security`.* - - Release assets are published at: - + + *The `Allow Anyway` button is only shown for a limited time after the blocked launch attempt, so if you do not see + it, run `./bifrost` again and return to `Privacy & Security`.* + + Release assets are published at: + - [github.com/bifrostsec/bifrost-cli/releases/latest](https://github.com/bifrostsec/bifrost-cli/releases/latest) - - Available executable names include: - + + Available executable names include: + - `bifrost-darwin-amd64` - `bifrost-darwin-arm64` - `bifrost-linux-amd64` - `bifrost-linux-arm64` - `bifrost-windows-386` - `bifrost-windows-amd64` - - ### Or build the CLI from source: - + + ### Or build the CLI from source: + ```bash make build ``` @@ -106,12 +111,30 @@ You can control retry behavior for transient upload failures: ./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: +Git metadata is optional. You can attach it manually: ```bash ./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 ``` +You can also enable automatic Git metadata detection. When enabled, bifrost fills in missing Git metadata from the +current Git repository when those values are available: + +```bash +./bifrost --service=my-service --service-version=1.2.3 --git-auto-detect sbom upload /path/to/sbom.json +``` + +To detect metadata from a specific path: + +```bash +./bifrost --service=my-service --service-version=1.2.3 --git-auto-detect --git-repo-path=/path/to/repo sbom upload /path/to/sbom.json +``` + +You can enable automatic Git metadata detection with: + +- The `BIFROST_GIT_AUTO_DETECT=true` environment variable +- The `--git-auto-detect` flag + Example with Trivy generating a CycloneDX SBOM for a container image and piping it directly to bifrost: ```bash @@ -130,19 +153,21 @@ 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. | -| `--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. | +| `--help` | No | | Show help and exit. | ## Useful Links diff --git a/internal/bifrost/api.go b/internal/bifrost/api.go index 0f45e17..0510b19 100644 --- a/internal/bifrost/api.go +++ b/internal/bifrost/api.go @@ -35,7 +35,7 @@ type APIConfig struct { GitCommitSHA string GitOrigin string Image string - CliVersion string + CliVersion string } type api struct { diff --git a/internal/bifrost/cli.go b/internal/bifrost/cli.go index db8f004..85837f4 100644 --- a/internal/bifrost/cli.go +++ b/internal/bifrost/cli.go @@ -32,7 +32,7 @@ func CLI(version, gitCommit string, args []string) int { printUsage(fl) return 2 } - err = ValidateBaseOptions(&options) + err = ValidateBaseOptions(fl, &options) if err != nil { _, _ = fmt.Fprintf(os.Stderr, "Error: %v\n\n", err) printUsage(fl) diff --git a/internal/bifrost/cli_test.go b/internal/bifrost/cli_test.go index 5fa3924..5063c37 100644 --- a/internal/bifrost/cli_test.go +++ b/internal/bifrost/cli_test.go @@ -4,11 +4,15 @@ package bifrost import ( + "errors" "fmt" "io" "net/http" "net/http/httptest" "os" + "os/exec" + "path/filepath" + "strings" "testing" "time" @@ -40,8 +44,11 @@ func TestCLI_ValidCommand(t *testing.T) { } // Run the CLI with the parsed arguments - exitCode := CLI("1.0", "commit", args) + exitCode, stderr := captureStderr(t, func() int { + return CLI("1.0", "commit", args) + }) assert.Equal(t, 0, exitCode) + assert.Contains(t, stderr, missingGitMetadataHint) } func TestCLI_ValidCommandWithGitMetadata(t *testing.T) { @@ -72,10 +79,227 @@ func TestCLI_ValidCommandWithGitMetadata(t *testing.T) { "sbom", "upload", path, } + exitCode, stderr := captureStderr(t, func() int { + return CLI("1.0", "commit", args) + }) + assert.Equal(t, 0, exitCode) + assert.NotContains(t, stderr, missingGitMetadataHint) +} + +func TestCLI_ValidCommandWithGitAutoDetectEnabledByFlag(t *testing.T) { + branch := "feature/auto-git-metadata" + origin := "https://github.com/example/auto-project.git" + repoDir, commitSHA := createTestGitRepo(t, branch, origin) + chdir(t, repoDir) + + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, branch, r.URL.Query().Get("git_branch")) + assert.Equal(t, commitSHA, r.URL.Query().Get("git_commit_sha")) + assert.Equal(t, origin, r.URL.Query().Get("git_origin")) + 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) + + args := []string{ + fmt.Sprintf("--server-url=%s", httpServer.URL), + "--service=test-service", + "--service-version=1.0", + "--api-key=test-token", + "--git-auto-detect", + "sbom", "upload", path, + } + + exitCode, stderr := captureStderr(t, func() int { + return CLI("1.0", "commit", args) + }) + assert.Equal(t, 0, exitCode) + assertGitMetadataDetection(t, stderr, ".", branch, commitSHA, origin) + assert.NotContains(t, stderr, missingGitMetadataHint) +} + +func TestCLI_ValidCommandWithGitAutoDetectFromGitRepoPath(t *testing.T) { + branch := "feature/auto-git-metadata-path" + origin := "https://github.com/example/auto-project-path.git" + repoDir, commitSHA := createTestGitRepo(t, branch, origin) + chdir(t, t.TempDir()) + + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, branch, r.URL.Query().Get("git_branch")) + assert.Equal(t, commitSHA, r.URL.Query().Get("git_commit_sha")) + assert.Equal(t, origin, r.URL.Query().Get("git_origin")) + 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) + + args := []string{ + fmt.Sprintf("--server-url=%s", httpServer.URL), + "--service=test-service", + "--service-version=1.0", + "--api-key=test-token", + "--git-auto-detect", + fmt.Sprintf("--git-repo-path=%s", repoDir), + "sbom", "upload", path, + } + + exitCode, stderr := captureStderr(t, func() int { + return CLI("1.0", "commit", args) + }) + assert.Equal(t, 0, exitCode) + assertGitMetadataDetection(t, stderr, repoDir, branch, commitSHA, origin) + assert.NotContains(t, stderr, missingGitMetadataHint) +} + +func TestCLI_ValidCommandInGitRepoWithoutGitAutoDetectOmitsGitMetadata(t *testing.T) { + branch := "feature/default-no-auto-git-metadata" + origin := "https://github.com/example/default-no-auto-project.git" + repoDir, _ := createTestGitRepo(t, branch, origin) + chdir(t, repoDir) + + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertNoGitMetadataQuery(t, r) + 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) + + args := []string{ + fmt.Sprintf("--server-url=%s", httpServer.URL), + "--service=test-service", + "--service-version=1.0", + "--api-key=test-token", + "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" + origin := "https://github.com/example/flag-overrides-env-project.git" + repoDir, _ := createTestGitRepo(t, branch, origin) + chdir(t, repoDir) + + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertNoGitMetadataQuery(t, r) + 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) + + args := []string{ + fmt.Sprintf("--server-url=%s", httpServer.URL), + "--service=test-service", + "--service-version=1.0", + "--api-key=test-token", + "--git-auto-detect=false", + "sbom", "upload", path, + } + + exitCode, stderr := captureStderr(t, func() int { + return CLI("1.0", "commit", args) + }) + assert.Equal(t, 0, exitCode) + assert.Contains(t, stderr, missingGitMetadataHint) +} + +func TestCLI_InvalidGitAutoDetectEnvironmentValue(t *testing.T) { + t.Setenv("BIFROST_GIT_AUTO_DETECT", "notabool") + + args := []string{ + "--server-url=https://portal.bifrostsec.com", + "--service=test-service", + "--service-version=1.0", + "--api-key=test-token", + "sbom", "upload", "test-sbom.json", + } + + exitCode, stderr := captureStderr(t, func() int { + return CLI("1.0", "commit", args) + }) + assert.Equal(t, 2, exitCode) + assert.Contains(t, stderr, "BIFROST_GIT_AUTO_DETECT must be a boolean") +} + +func TestCLI_ValidCommandWithGitAutoDetectEnabledByEnvironment(t *testing.T) { + t.Setenv("BIFROST_GIT_AUTO_DETECT", "true") + branch := "feature/env-enabled-auto-git-metadata" + origin := "https://github.com/example/env-enabled-project.git" + repoDir, commitSHA := createTestGitRepo(t, branch, origin) + chdir(t, repoDir) + + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, branch, r.URL.Query().Get("git_branch")) + assert.Equal(t, commitSHA, r.URL.Query().Get("git_commit_sha")) + assert.Equal(t, origin, r.URL.Query().Get("git_origin")) + 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) + + args := []string{ + fmt.Sprintf("--server-url=%s", httpServer.URL), + "--service=test-service", + "--service-version=1.0", + "--api-key=test-token", + "sbom", "upload", path, + } + exitCode := CLI("1.0", "commit", args) assert.Equal(t, 0, exitCode) } +func TestCLI_ValidCommandWithGitAutoDetectOutsideGitRepoPrintsError(t *testing.T) { + tempDir := t.TempDir() + chdir(t, tempDir) + + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertNoGitMetadataQuery(t, r) + 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) + + args := []string{ + fmt.Sprintf("--server-url=%s", httpServer.URL), + "--service=test-service", + "--service-version=1.0", + "--api-key=test-token", + "--git-auto-detect", + "sbom", "upload", path, + } + + exitCode, stderr := captureStderr(t, func() int { + return CLI("1.0", "commit", args) + }) + assert.Equal(t, 0, exitCode) + assertGitMetadataDetection(t, stderr, ".", "", "", "") + assert.Contains(t, stderr, " error=\"check git work tree:") + assert.Contains(t, stderr, "git -C \\\".\\\" rev-parse --is-inside-work-tree") + assert.Contains(t, stderr, missingGitMetadataHint) +} + func TestCLI_ValidCommandWithImage(t *testing.T) { httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Empty(t, r.URL.Query().Get("version")) @@ -246,6 +470,138 @@ func TestCLI_StdinPathRequiresPipedInput(t *testing.T) { assert.Equal(t, 2, exitCode) } +func createTestGitRepo(t *testing.T, branch string, origin string) (string, string) { + t.Helper() + + repoDir := t.TempDir() + runTestGit(t, repoDir, "init") + runTestGit(t, repoDir, "config", "user.email", "test@example.com") + runTestGit(t, repoDir, "config", "user.name", "Test User") + runTestGit(t, repoDir, "config", "commit.gpgsign", "false") + runTestGit(t, repoDir, "checkout", "-b", branch) + runTestGit(t, repoDir, "remote", "add", "origin", origin) + + err := os.WriteFile(filepath.Join(repoDir, "tracked.txt"), []byte("tracked\n"), 0644) + assert.NoError(t, err) + runTestGit(t, repoDir, "add", "tracked.txt") + runTestGit(t, repoDir, "commit", "-m", "initial commit") + + commitSHA := runTestGit(t, repoDir, "rev-parse", "HEAD") + return repoDir, commitSHA +} + +func runTestGit(t *testing.T, dir string, args ...string) string { + t.Helper() + + gitArgs := append([]string{"-C", dir}, args...) + cmd := exec.Command("git", gitArgs...) + output, err := cmd.CombinedOutput() + if err != nil { + var execErr *exec.Error + if errors.As(err, &execErr) { + t.Skip("git is not available") + } + t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, string(output)) + } + return strings.TrimSpace(string(output)) +} + +func chdir(t *testing.T, dir string) { + t.Helper() + + previousDir, err := os.Getwd() + if err != nil { + t.Fatalf("failed to read current directory: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("failed to change directory to %s: %v", dir, err) + } + t.Cleanup(func() { + if err := os.Chdir(previousDir); err != nil { + t.Fatalf("failed to restore directory to %s: %v", previousDir, err) + } + }) +} + +func assertNoGitMetadataQuery(t *testing.T, r *http.Request) { + t.Helper() + + query := r.URL.Query() + _, hasGitBranch := query["git_branch"] + _, hasGitCommitSHA := query["git_commit_sha"] + _, hasGitOrigin := query["git_origin"] + assert.False(t, hasGitBranch) + assert.False(t, hasGitCommitSHA) + assert.False(t, hasGitOrigin) +} + +func assertGitMetadataDetection(t *testing.T, stderr string, repoPath string, branch string, commitSHA string, origin string) { + t.Helper() + + assert.Contains(t, stderr, fmt.Sprintf("Git metadata detection from %s:\n", repoPath)) + assert.Contains(t, stderr, fmt.Sprintf(" git_branch=%q\n", branch)) + assert.Contains(t, stderr, fmt.Sprintf(" git_commit_sha=%q\n", commitSHA)) + assert.Contains(t, stderr, fmt.Sprintf(" git_origin=%q\n", origin)) +} + +func captureStderr(t *testing.T, run func() int) (int, string) { + t.Helper() + + readPipe, writePipe, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create stderr pipe: %v", err) + } + defer func() { + _ = readPipe.Close() + }() + + originalStderr := os.Stderr + os.Stderr = writePipe + writePipeClosed := false + defer func() { + os.Stderr = originalStderr + if !writePipeClosed { + _ = writePipe.Close() + } + }() + + exitCode := run() + os.Stderr = originalStderr + + err = writePipe.Close() + writePipeClosed = true + if err != nil { + t.Fatalf("failed to close stderr pipe: %v", err) + } + + output, err := io.ReadAll(readPipe) + if err != nil { + t.Fatalf("failed to read stderr pipe: %v", err) + } + return exitCode, string(output) +} + +func TestCaptureStderrRestoresStderrAfterPanic(t *testing.T) { + originalStderr := os.Stderr + defer func() { + os.Stderr = originalStderr + }() + + panicked := false + func() { + defer func() { + panicked = recover() != nil + }() + + _, _ = captureStderr(t, func() int { + panic("test panic") + }) + }() + + assert.True(t, panicked) + assert.Same(t, originalStderr, os.Stderr) +} + func TestCLI_InvalidCommand(t *testing.T) { httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/internal/bifrost/git_metadata.go b/internal/bifrost/git_metadata.go new file mode 100644 index 0000000..cd9d65f --- /dev/null +++ b/internal/bifrost/git_metadata.go @@ -0,0 +1,78 @@ +// Copyright 2026 bifrost security +// SPDX-License-Identifier: Apache-2.0 + +package bifrost + +import ( + "fmt" + "os/exec" + "strings" +) + +type gitMetadata struct { + branch string + commitSHA string + origin string +} + +type gitMetadataDiscovery struct { + metadata gitMetadata + errors []error +} + +func discoverGitMetadata(dir string) gitMetadataDiscovery { + // Verify that we are inside a Git repository. + insideWorkingTree, err := runGitCommand(dir, "rev-parse", "--is-inside-work-tree") + if err != nil { + return gitMetadataDiscovery{ + errors: []error{fmt.Errorf("check git work tree: %w", err)}, + } + } + if insideWorkingTree != "true" { + return gitMetadataDiscovery{ + errors: []error{fmt.Errorf("check git work tree: not inside a Git work tree")}, + } + } + + var errors []error + read := func(label string, args ...string) string { + value, err := runGitCommand(dir, args...) + if err != nil { + errors = append(errors, fmt.Errorf("%s: %w", label, err)) + } + return value + } + + // Read branch and commit metadata. + branch := read("read git branch", "rev-parse", "--abbrev-ref", "HEAD") + if branch == "HEAD" { + branch = "" + } + commitSHA := read("read git commit SHA", "rev-parse", "HEAD") + + // Get remote origin + origin, _ := runGitCommand(dir, "config", "--get", "remote.origin.url") + + return gitMetadataDiscovery{ + metadata: gitMetadata{ + branch: branch, + commitSHA: commitSHA, + origin: origin, + }, + errors: errors, + } +} + +func runGitCommand(dir string, args ...string) (string, error) { + gitArgs := append([]string{"-C", dir}, args...) + output, err := exec.Command("git", gitArgs...).CombinedOutput() + message := strings.TrimSpace(string(output)) + if err != nil { + command := fmt.Sprintf("git -C %q %s", dir, strings.Join(args, " ")) + if message == "" { + return "", fmt.Errorf("%s: %w", command, err) + } + return "", fmt.Errorf("%s: %w: %s", command, err, message) + } + return message, nil +} diff --git a/internal/bifrost/options.go b/internal/bifrost/options.go index de3083f..102d1a8 100644 --- a/internal/bifrost/options.go +++ b/internal/bifrost/options.go @@ -8,9 +8,12 @@ import ( "fmt" "net/url" "os" + "strconv" "time" ) +const gitAutoDetectFlag = "git-auto-detect" + type Options struct { ServerURL string apiKey string @@ -22,6 +25,8 @@ type Options struct { gitBranch string gitCommitSHA string gitOrigin string + gitRepoPath string + gitAutoDetect bool } func RegisterOptions(fl *flag.FlagSet, opts *Options) { @@ -35,9 +40,11 @@ func RegisterOptions(fl *flag.FlagSet, opts *Options) { fl.StringVar(&opts.gitBranch, "git-branch", "", "Optional Git branch name for the uploaded SBOM") fl.StringVar(&opts.gitCommitSHA, "git-commit-sha", "", "Optional Git commit SHA for the uploaded SBOM") 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)") } -func ValidateBaseOptions(opts *Options) error { +func ValidateBaseOptions(fl *flag.FlagSet, opts *Options) error { if u := os.Getenv("SERVER_URL"); u != "" { opts.ServerURL = u } @@ -61,6 +68,26 @@ func ValidateBaseOptions(opts *Options) error { if opts.retryDelay < 0 { return fmt.Errorf("retry delay must be zero or greater") } + // An explicitly passed flag takes precedence over the environment variable. + if !isFlagSet(fl, gitAutoDetectFlag) { + if value := os.Getenv("BIFROST_GIT_AUTO_DETECT"); value != "" { + gitAutoDetect, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("BIFROST_GIT_AUTO_DETECT must be a boolean") + } + opts.gitAutoDetect = gitAutoDetect + } + } return nil } + +func isFlagSet(fl *flag.FlagSet, name string) bool { + isSet := false + fl.Visit(func(f *flag.Flag) { + if f.Name == name { + isSet = true + } + }) + return isSet +} diff --git a/internal/bifrost/sbom_upload.go b/internal/bifrost/sbom_upload.go index a450553..3223951 100644 --- a/internal/bifrost/sbom_upload.go +++ b/internal/bifrost/sbom_upload.go @@ -16,6 +16,9 @@ type sbomUploadTask struct { cliVersion string } +const missingGitMetadataHint = "Hint: no Git metadata was provided. To automatically attach Git metadata, run from a Git repository with --git-auto-detect or set the BIFROST_GIT_AUTO_DETECT=true environment variable. Use --git-repo-path when the repository is elsewhere.\n" +const gitMetadataDetectionMessage = "Git metadata detection from %s:\n git_branch=%q\n git_commit_sha=%q\n git_origin=%q\n" + func NewSBOMUploadTask(opts Options, args []string, cliVersion string) (Task, error) { if opts.service == "" { opts.service = os.Getenv("SERVICE") @@ -37,6 +40,12 @@ func NewSBOMUploadTask(opts Options, args []string, cliVersion string) (Task, er if len(args) == 0 { return nil, fmt.Errorf("at least one SBOM file path is required") } + if opts.gitAutoDetect { + gitMetadataDetection := discoverGitMetadata(opts.gitRepoPath) + printGitMetadataDetection(opts.gitRepoPath, gitMetadataDetection) + opts = applyGitMetadataDetection(opts, gitMetadataDetection.metadata) + } + return &sbomUploadTask{ Options: opts, paths: args, @@ -78,9 +87,44 @@ func (t sbomUploadTask) Run(ctx context.Context) error { } fmt.Printf("Uploaded %s to %s\n", path, t.ServerURL) } + t.printGitMetadataHint() return nil } +func printGitMetadataDetection(gitRepoPath string, discovery gitMetadataDiscovery) { + _, _ = fmt.Fprintf( + os.Stderr, + gitMetadataDetectionMessage, + gitRepoPath, + discovery.metadata.branch, + discovery.metadata.commitSHA, + discovery.metadata.origin, + ) + for _, err := range discovery.errors { + _, _ = fmt.Fprintf(os.Stderr, " error=%q\n", err) + } +} + +func applyGitMetadataDetection(opts Options, metadata gitMetadata) Options { + if opts.gitBranch == "" { + opts.gitBranch = metadata.branch + } + if opts.gitCommitSHA == "" { + opts.gitCommitSHA = metadata.commitSHA + } + if opts.gitOrigin == "" { + opts.gitOrigin = metadata.origin + } + return opts +} + +func (t sbomUploadTask) printGitMetadataHint() { + if t.gitBranch != "" || t.gitCommitSHA != "" || t.gitOrigin != "" { + return + } + _, _ = fmt.Fprint(os.Stderr, missingGitMetadataHint) +} + func (t sbomUploadTask) uploadStdinSBOM(ctx context.Context, api API) error { fi, err := os.Stdin.Stat() if err != nil {