From c6f447b607a7c497c7cdf5ffb409b061f43128f3 Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Thu, 2 Jul 2026 18:21:53 +0200 Subject: [PATCH 01/18] feat: add support for auto-discovery of Git metadata during SBOM uploads --- internal/bifrost/cli_test.go | 119 +++++++++++++++++++++++++++++++ internal/bifrost/git_metadata.go | 66 +++++++++++++++++ internal/bifrost/sbom_upload.go | 2 + 3 files changed, 187 insertions(+) create mode 100644 internal/bifrost/git_metadata.go diff --git a/internal/bifrost/cli_test.go b/internal/bifrost/cli_test.go index 5fa3924..d56827b 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" @@ -76,6 +80,68 @@ func TestCLI_ValidCommandWithGitMetadata(t *testing.T) { assert.Equal(t, 0, exitCode) } +func TestCLI_ValidCommandWithAutoGitMetadata(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", + "sbom", "upload", path, + } + + exitCode := CLI("1.0", "commit", args) + assert.Equal(t, 0, exitCode) +} + +func TestCLI_ValidCommandOutsideGitRepoOmitsGitMetadata(t *testing.T) { + tempDir := t.TempDir() + chdir(t, tempDir) + + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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) + 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_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 +312,59 @@ 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 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..e808b4c --- /dev/null +++ b/internal/bifrost/git_metadata.go @@ -0,0 +1,66 @@ +// Copyright 2026 bifrost security +// SPDX-License-Identifier: Apache-2.0 + +package bifrost + +import ( + "os/exec" + "strings" +) + +type gitMetadata struct { + branch string + commitSHA string + origin string +} + +func populateDefaultGitMetadata(opts *Options) { + if opts.gitBranch != "" && opts.gitCommitSHA != "" && opts.gitOrigin != "" { + return + } + + metadata, ok := discoverGitMetadata(".") + if !ok { + return + } + + if opts.gitBranch == "" { + opts.gitBranch = metadata.branch + } + if opts.gitCommitSHA == "" { + opts.gitCommitSHA = metadata.commitSHA + } + if opts.gitOrigin == "" { + opts.gitOrigin = metadata.origin + } +} + +func discoverGitMetadata(dir string) (gitMetadata, bool) { + insideWorkTree, ok := runGit(dir, "rev-parse", "--is-inside-work-tree") + if !ok || insideWorkTree != "true" { + return gitMetadata{}, false + } + + branch, _ := runGit(dir, "rev-parse", "--abbrev-ref", "HEAD") + if branch == "HEAD" { + branch = "" + } + commitSHA, _ := runGit(dir, "rev-parse", "HEAD") + origin, _ := runGit(dir, "config", "--get", "remote.origin.url") + + metadata := gitMetadata{ + branch: branch, + commitSHA: commitSHA, + origin: origin, + } + return metadata, metadata.branch != "" || metadata.commitSHA != "" || metadata.origin != "" +} + +func runGit(dir string, args ...string) (string, bool) { + gitArgs := append([]string{"-C", dir}, args...) + output, err := exec.Command("git", gitArgs...).Output() + if err != nil { + return "", false + } + return strings.TrimSpace(string(output)), true +} diff --git a/internal/bifrost/sbom_upload.go b/internal/bifrost/sbom_upload.go index a450553..dc0764e 100644 --- a/internal/bifrost/sbom_upload.go +++ b/internal/bifrost/sbom_upload.go @@ -37,6 +37,8 @@ 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") } + populateDefaultGitMetadata(&opts) + return &sbomUploadTask{ Options: opts, paths: args, From 235ab14a1c9903f822347165370073594fef144b Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Mon, 6 Jul 2026 14:17:33 +0200 Subject: [PATCH 02/18] feat: add flag for enabling Git metadata auto-detection --- README.md | 13 ++++- internal/bifrost/cli_test.go | 83 +++++++++++++++++++++++++++++--- internal/bifrost/git_metadata.go | 3 ++ internal/bifrost/options.go | 40 +++++++++++---- 4 files changed, 120 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 3acc2a1..970dad0 100644 --- a/README.md +++ b/README.md @@ -106,12 +106,23 @@ 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 --enable-auto-git-metadata sbom upload /path/to/sbom.json +``` + +You can enable automatic Git metadata detection with: + +- The `BIFROST_ENABLE_AUTO_GIT_METADATA=true` environment variable +- The `--enable-auto-git-metadata` flag + Example with Trivy generating a CycloneDX SBOM for a container image and piping it directly to bifrost: ```bash diff --git a/internal/bifrost/cli_test.go b/internal/bifrost/cli_test.go index d56827b..548f4eb 100644 --- a/internal/bifrost/cli_test.go +++ b/internal/bifrost/cli_test.go @@ -80,7 +80,7 @@ func TestCLI_ValidCommandWithGitMetadata(t *testing.T) { assert.Equal(t, 0, exitCode) } -func TestCLI_ValidCommandWithAutoGitMetadata(t *testing.T) { +func TestCLI_ValidCommandWithAutoGitMetadataEnabledByFlag(t *testing.T) { branch := "feature/auto-git-metadata" origin := "https://github.com/example/auto-project.git" repoDir, commitSHA := createTestGitRepo(t, branch, origin) @@ -98,6 +98,67 @@ func TestCLI_ValidCommandWithAutoGitMetadata(t *testing.T) { 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", + "--enable-auto-git-metadata", + "sbom", "upload", path, + } + + exitCode := CLI("1.0", "commit", args) + assert.Equal(t, 0, exitCode) +} + +func TestCLI_ValidCommandInGitRepoWithoutAutoGitMetadataOmitsGitMetadata(t *testing.T) { + t.Setenv("BIFROST_ENABLE_AUTO_GIT_METADATA", "false") + 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_ValidCommandWithAutoGitMetadataEnabledByEnvironment(t *testing.T) { + t.Setenv("BIFROST_ENABLE_AUTO_GIT_METADATA", "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", @@ -115,13 +176,7 @@ func TestCLI_ValidCommandOutsideGitRepoOmitsGitMetadata(t *testing.T) { chdir(t, tempDir) httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - 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) + assertNoGitMetadataQuery(t, r) w.WriteHeader(http.StatusOK) })) defer httpServer.Close() @@ -365,6 +420,18 @@ func chdir(t *testing.T, dir string) { }) } +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 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 index e808b4c..add5ff6 100644 --- a/internal/bifrost/git_metadata.go +++ b/internal/bifrost/git_metadata.go @@ -15,6 +15,9 @@ type gitMetadata struct { } func populateDefaultGitMetadata(opts *Options) { + if !opts.enableAutoGitMetadata { + return + } if opts.gitBranch != "" && opts.gitCommitSHA != "" && opts.gitOrigin != "" { return } diff --git a/internal/bifrost/options.go b/internal/bifrost/options.go index de3083f..b9cd5a3 100644 --- a/internal/bifrost/options.go +++ b/internal/bifrost/options.go @@ -8,20 +8,22 @@ import ( "fmt" "net/url" "os" + "strconv" "time" ) type Options struct { - ServerURL string - apiKey string - service string - serviceVersion string - image string - retryAttempts int - retryDelay time.Duration - gitBranch string - gitCommitSHA string - gitOrigin string + ServerURL string + apiKey string + service string + serviceVersion string + image string + retryAttempts int + retryDelay time.Duration + gitBranch string + gitCommitSHA string + gitOrigin string + enableAutoGitMetadata bool } func RegisterOptions(fl *flag.FlagSet, opts *Options) { @@ -35,6 +37,7 @@ 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.BoolVar(&opts.enableAutoGitMetadata, "enable-auto-git-metadata", false, "Enable automatic Git metadata detection (or BIFROST_ENABLE_AUTO_GIT_METADATA=true)") } func ValidateBaseOptions(opts *Options) error { @@ -61,6 +64,23 @@ func ValidateBaseOptions(opts *Options) error { if opts.retryDelay < 0 { return fmt.Errorf("retry delay must be zero or greater") } + enableAutoGitMetadata, err := boolFromEnv("BIFROST_ENABLE_AUTO_GIT_METADATA") + if err != nil { + return err + } + opts.enableAutoGitMetadata = opts.enableAutoGitMetadata || enableAutoGitMetadata return nil } + +func boolFromEnv(name string) (bool, error) { + value := os.Getenv(name) + if value == "" { + return false, nil + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return false, fmt.Errorf("%s must be a boolean", name) + } + return parsed, nil +} From 9885bfa5b954b23af6c0a8fdadf2ae5955ccd32b Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Mon, 6 Jul 2026 14:26:55 +0200 Subject: [PATCH 03/18] docs: improve README formatting and add details for Git metadata detection --- README.md | 86 ++++++++++++++++++++++------------------- internal/bifrost/api.go | 2 +- 2 files changed, 48 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 970dad0..017bca3 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,16 @@ 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? 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/) @@ -21,59 +24,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 ``` @@ -112,7 +118,8 @@ Git metadata is optional. You can attach it manually: ./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: +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 --enable-auto-git-metadata sbom upload /path/to/sbom.json @@ -141,19 +148,20 @@ 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. | +| `--enable-auto-git-metadata` | No | `BIFROST_ENABLE_AUTO_GIT_METADATA` | 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 { From f085429ca7559dca2fea93f0979739c7ca62e32d Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Tue, 7 Jul 2026 08:37:39 +0200 Subject: [PATCH 04/18] refactor: improve Git metadata handling, add hint for missing metadata, and simplify environment variable parsing --- internal/bifrost/cli_test.go | 102 ++++++++++++++++++++++++++++++- internal/bifrost/git_metadata.go | 5 +- internal/bifrost/options.go | 24 +++----- internal/bifrost/sbom_upload.go | 14 ++++- 4 files changed, 120 insertions(+), 25 deletions(-) diff --git a/internal/bifrost/cli_test.go b/internal/bifrost/cli_test.go index 548f4eb..4b564ac 100644 --- a/internal/bifrost/cli_test.go +++ b/internal/bifrost/cli_test.go @@ -44,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) { @@ -76,8 +79,11 @@ func TestCLI_ValidCommandWithGitMetadata(t *testing.T) { "sbom", "upload", path, } - exitCode := CLI("1.0", "commit", args) + exitCode, stderr := captureStderr(t, func() int { + return CLI("1.0", "commit", args) + }) assert.Equal(t, 0, exitCode) + assert.NotContains(t, stderr, missingGitMetadataHint) } func TestCLI_ValidCommandWithAutoGitMetadataEnabledByFlag(t *testing.T) { @@ -112,7 +118,6 @@ func TestCLI_ValidCommandWithAutoGitMetadataEnabledByFlag(t *testing.T) { } func TestCLI_ValidCommandInGitRepoWithoutAutoGitMetadataOmitsGitMetadata(t *testing.T) { - t.Setenv("BIFROST_ENABLE_AUTO_GIT_METADATA", "false") branch := "feature/default-no-auto-git-metadata" origin := "https://github.com/example/default-no-auto-project.git" repoDir, _ := createTestGitRepo(t, branch, origin) @@ -140,6 +145,70 @@ func TestCLI_ValidCommandInGitRepoWithoutAutoGitMetadataOmitsGitMetadata(t *test assert.Equal(t, 0, exitCode) } +func TestCLI_ValidCommandWithAutoGitMetadataExplicitlyDisabledByFlagPrintsHint(t *testing.T) { + branch := "feature/flag-disabled-auto-git-metadata" + origin := "https://github.com/example/flag-disabled-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", + "--enable-auto-git-metadata=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_ValidCommandWithAutoGitMetadataExplicitlyDisabledByEnvironmentPrintsHint(t *testing.T) { + t.Setenv("BIFROST_ENABLE_AUTO_GIT_METADATA", "false") + branch := "feature/env-disabled-auto-git-metadata" + origin := "https://github.com/example/env-disabled-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, stderr := captureStderr(t, func() int { + return CLI("1.0", "commit", args) + }) + assert.Equal(t, 0, exitCode) + assert.Contains(t, stderr, missingGitMetadataHint) +} + func TestCLI_ValidCommandWithAutoGitMetadataEnabledByEnvironment(t *testing.T) { t.Setenv("BIFROST_ENABLE_AUTO_GIT_METADATA", "true") branch := "feature/env-enabled-auto-git-metadata" @@ -432,6 +501,33 @@ func assertNoGitMetadataQuery(t *testing.T, r *http.Request) { assert.False(t, hasGitOrigin) } +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 + exitCode := run() + os.Stderr = originalStderr + + if err := writePipe.Close(); 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 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 index add5ff6..7662936 100644 --- a/internal/bifrost/git_metadata.go +++ b/internal/bifrost/git_metadata.go @@ -14,10 +14,7 @@ type gitMetadata struct { origin string } -func populateDefaultGitMetadata(opts *Options) { - if !opts.enableAutoGitMetadata { - return - } +func fillMissingGitMetadataFromRepo(opts *Options) { if opts.gitBranch != "" && opts.gitCommitSHA != "" && opts.gitOrigin != "" { return } diff --git a/internal/bifrost/options.go b/internal/bifrost/options.go index b9cd5a3..6196976 100644 --- a/internal/bifrost/options.go +++ b/internal/bifrost/options.go @@ -37,7 +37,7 @@ 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.BoolVar(&opts.enableAutoGitMetadata, "enable-auto-git-metadata", false, "Enable automatic Git metadata detection (or BIFROST_ENABLE_AUTO_GIT_METADATA=true)") + fl.BoolVar(&opts.enableAutoGitMetadata, "enable-auto-git-metadata", false, "Enable automatic Git metadata detection (or environment variable BIFROST_ENABLE_AUTO_GIT_METADATA=true)") } func ValidateBaseOptions(opts *Options) error { @@ -64,23 +64,13 @@ func ValidateBaseOptions(opts *Options) error { if opts.retryDelay < 0 { return fmt.Errorf("retry delay must be zero or greater") } - enableAutoGitMetadata, err := boolFromEnv("BIFROST_ENABLE_AUTO_GIT_METADATA") - if err != nil { - return err + if opts.enableAutoGitMetadata == false { + enableAutoGitMetadata, err := strconv.ParseBool(os.Getenv("BIFROST_ENABLE_AUTO_GIT_METADATA")) + if err != nil { + return err + } + opts.enableAutoGitMetadata = opts.enableAutoGitMetadata || enableAutoGitMetadata } - opts.enableAutoGitMetadata = opts.enableAutoGitMetadata || enableAutoGitMetadata return nil } - -func boolFromEnv(name string) (bool, error) { - value := os.Getenv(name) - if value == "" { - return false, nil - } - parsed, err := strconv.ParseBool(value) - if err != nil { - return false, fmt.Errorf("%s must be a boolean", name) - } - return parsed, nil -} diff --git a/internal/bifrost/sbom_upload.go b/internal/bifrost/sbom_upload.go index dc0764e..c9f9f69 100644 --- a/internal/bifrost/sbom_upload.go +++ b/internal/bifrost/sbom_upload.go @@ -16,6 +16,8 @@ type sbomUploadTask struct { cliVersion string } +const missingGitMetadataHint = "Hint: no Git metadata was provided. To automatically attach Git metadata, run from a Git repository with --enable-auto-git-metadata or set the BIFROST_ENABLE_AUTO_GIT_METADATA=true environment variable.\n" + func NewSBOMUploadTask(opts Options, args []string, cliVersion string) (Task, error) { if opts.service == "" { opts.service = os.Getenv("SERVICE") @@ -37,7 +39,9 @@ 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") } - populateDefaultGitMetadata(&opts) + if opts.enableAutoGitMetadata { + fillMissingGitMetadataFromRepo(&opts) + } return &sbomUploadTask{ Options: opts, @@ -80,9 +84,17 @@ func (t sbomUploadTask) Run(ctx context.Context) error { } fmt.Printf("Uploaded %s to %s\n", path, t.ServerURL) } + t.printGitMetadataHint() return nil } +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 { From 40128f020b5ed3d78d01273094d8c434d982ebb2 Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Tue, 7 Jul 2026 08:50:36 +0200 Subject: [PATCH 05/18] fix: handle nil environment variable parsing --- internal/bifrost/options.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/bifrost/options.go b/internal/bifrost/options.go index 6196976..5c3e7b9 100644 --- a/internal/bifrost/options.go +++ b/internal/bifrost/options.go @@ -64,12 +64,14 @@ func ValidateBaseOptions(opts *Options) error { if opts.retryDelay < 0 { return fmt.Errorf("retry delay must be zero or greater") } - if opts.enableAutoGitMetadata == false { - enableAutoGitMetadata, err := strconv.ParseBool(os.Getenv("BIFROST_ENABLE_AUTO_GIT_METADATA")) - if err != nil { - return err + if !opts.enableAutoGitMetadata { + if value := os.Getenv("BIFROST_ENABLE_AUTO_GIT_METADATA"); value != "" { + enableAutoGitMetadata, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("BIFROST_ENABLE_AUTO_GIT_METADATA must be a boolean") + } + opts.enableAutoGitMetadata = enableAutoGitMetadata } - opts.enableAutoGitMetadata = opts.enableAutoGitMetadata || enableAutoGitMetadata } return nil From 2139495c16e7c543412b203ce80218f847b95bd9 Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Tue, 7 Jul 2026 11:10:29 +0200 Subject: [PATCH 06/18] feat: add support for specifying Git repository path for metadata detection - Added `--git-repo-path` flag for custom repository path during Git metadata auto-detection. - Enhanced automatic Git metadata detection to log discovered metadata. - Updated README with usage details for `--git-repo-path`. - Improved tests to verify `--git-repo-path` functionality. --- README.md | 35 +++++++++++++--------- internal/bifrost/cli_test.go | 51 +++++++++++++++++++++++++++++++- internal/bifrost/git_metadata.go | 34 +++++++-------------- internal/bifrost/options.go | 2 ++ internal/bifrost/sbom_upload.go | 31 +++++++++++++++++-- 5 files changed, 112 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 017bca3..7cbf7f2 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,12 @@ current Git repository when those values are available: ./bifrost --service=my-service --service-version=1.2.3 --enable-auto-git-metadata sbom upload /path/to/sbom.json ``` +To detect metadata from a specific path: + +```bash +./bifrost --service=my-service --service-version=1.2.3 --enable-auto-git-metadata --git-repo-path=/path/to/repo sbom upload /path/to/sbom.json +``` + You can enable automatic Git metadata detection with: - The `BIFROST_ENABLE_AUTO_GIT_METADATA=true` environment variable @@ -148,20 +154,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. | -| `--enable-auto-git-metadata` | No | `BIFROST_ENABLE_AUTO_GIT_METADATA` | 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. | +| `--enable-auto-git-metadata` | No | `BIFROST_ENABLE_AUTO_GIT_METADATA` | 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/cli_test.go b/internal/bifrost/cli_test.go index 4b564ac..e357b4f 100644 --- a/internal/bifrost/cli_test.go +++ b/internal/bifrost/cli_test.go @@ -113,8 +113,48 @@ func TestCLI_ValidCommandWithAutoGitMetadataEnabledByFlag(t *testing.T) { "sbom", "upload", path, } - exitCode := CLI("1.0", "commit", args) + exitCode, stderr := captureStderr(t, func() int { + return CLI("1.0", "commit", args) + }) assert.Equal(t, 0, exitCode) + assertAutoDetectedGitMetadata(t, stderr, ".", branch, commitSHA, origin) + assert.NotContains(t, stderr, missingGitMetadataHint) +} + +func TestCLI_ValidCommandWithAutoGitMetadataFromGitRepoPath(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", + "--enable-auto-git-metadata", + 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) + assertAutoDetectedGitMetadata(t, stderr, repoDir, branch, commitSHA, origin) + assert.NotContains(t, stderr, missingGitMetadataHint) } func TestCLI_ValidCommandInGitRepoWithoutAutoGitMetadataOmitsGitMetadata(t *testing.T) { @@ -501,6 +541,15 @@ func assertNoGitMetadataQuery(t *testing.T, r *http.Request) { assert.False(t, hasGitOrigin) } +func assertAutoDetectedGitMetadata(t *testing.T, stderr string, repoPath string, branch string, commitSHA string, origin string) { + t.Helper() + + assert.Contains(t, stderr, fmt.Sprintf("Auto-detected Git metadata 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() diff --git a/internal/bifrost/git_metadata.go b/internal/bifrost/git_metadata.go index 7662936..5a10d78 100644 --- a/internal/bifrost/git_metadata.go +++ b/internal/bifrost/git_metadata.go @@ -14,31 +14,10 @@ type gitMetadata struct { origin string } -func fillMissingGitMetadataFromRepo(opts *Options) { - if opts.gitBranch != "" && opts.gitCommitSHA != "" && opts.gitOrigin != "" { - return - } - - metadata, ok := discoverGitMetadata(".") - if !ok { - return - } - - if opts.gitBranch == "" { - opts.gitBranch = metadata.branch - } - if opts.gitCommitSHA == "" { - opts.gitCommitSHA = metadata.commitSHA - } - if opts.gitOrigin == "" { - opts.gitOrigin = metadata.origin - } -} - -func discoverGitMetadata(dir string) (gitMetadata, bool) { +func discoverGitMetadata(dir string) gitMetadata { insideWorkTree, ok := runGit(dir, "rev-parse", "--is-inside-work-tree") if !ok || insideWorkTree != "true" { - return gitMetadata{}, false + return gitMetadata{} } branch, _ := runGit(dir, "rev-parse", "--abbrev-ref", "HEAD") @@ -53,7 +32,7 @@ func discoverGitMetadata(dir string) (gitMetadata, bool) { commitSHA: commitSHA, origin: origin, } - return metadata, metadata.branch != "" || metadata.commitSHA != "" || metadata.origin != "" + return metadata } func runGit(dir string, args ...string) (string, bool) { @@ -64,3 +43,10 @@ func runGit(dir string, args ...string) (string, bool) { } return strings.TrimSpace(string(output)), true } + +func gitMetadataRepoPath(path string) string { + if path == "" { + return "." + } + return path +} diff --git a/internal/bifrost/options.go b/internal/bifrost/options.go index 5c3e7b9..514936d 100644 --- a/internal/bifrost/options.go +++ b/internal/bifrost/options.go @@ -23,6 +23,7 @@ type Options struct { gitBranch string gitCommitSHA string gitOrigin string + gitRepoPath string enableAutoGitMetadata bool } @@ -37,6 +38,7 @@ 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.enableAutoGitMetadata, "enable-auto-git-metadata", false, "Enable automatic Git metadata detection (or environment variable BIFROST_ENABLE_AUTO_GIT_METADATA=true)") } diff --git a/internal/bifrost/sbom_upload.go b/internal/bifrost/sbom_upload.go index c9f9f69..70cc29b 100644 --- a/internal/bifrost/sbom_upload.go +++ b/internal/bifrost/sbom_upload.go @@ -16,7 +16,8 @@ type sbomUploadTask struct { cliVersion string } -const missingGitMetadataHint = "Hint: no Git metadata was provided. To automatically attach Git metadata, run from a Git repository with --enable-auto-git-metadata or set the BIFROST_ENABLE_AUTO_GIT_METADATA=true environment variable.\n" +const missingGitMetadataHint = "Hint: no Git metadata was provided. To automatically attach Git metadata, run from a Git repository with --enable-auto-git-metadata or set the BIFROST_ENABLE_AUTO_GIT_METADATA=true environment variable. Use --git-repo-path when the repository is elsewhere.\n" +const autoDetectedGitMetadataMessage = "Auto-detected Git metadata 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 == "" { @@ -40,7 +41,9 @@ func NewSBOMUploadTask(opts Options, args []string, cliVersion string) (Task, er return nil, fmt.Errorf("at least one SBOM file path is required") } if opts.enableAutoGitMetadata { - fillMissingGitMetadataFromRepo(&opts) + autoDetectedGitMetadata := discoverGitMetadata(gitMetadataRepoPath(opts.gitRepoPath)) + printAutoDetectedGitMetadata(opts.gitRepoPath, autoDetectedGitMetadata) + opts = withMissingGitMetadata(opts, autoDetectedGitMetadata) } return &sbomUploadTask{ @@ -88,6 +91,30 @@ func (t sbomUploadTask) Run(ctx context.Context) error { return nil } +func printAutoDetectedGitMetadata(repoPath string, metadata gitMetadata) { + _, _ = fmt.Fprintf( + os.Stderr, + autoDetectedGitMetadataMessage, + gitMetadataRepoPath(repoPath), + metadata.branch, + metadata.commitSHA, + metadata.origin, + ) +} + +func withMissingGitMetadata(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 From ed381934fb10d1809c9f73176b8dd1f672056457 Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Tue, 7 Jul 2026 11:35:36 +0200 Subject: [PATCH 07/18] refactor: use go-git for Git metadata discovery - Replaced manual shell-based Git metadata extraction with go-git library. - Enhanced error handling for Git discovery process. - Updated tests to reflect changes in Git metadata handling. - Adjusted dependencies in `go.mod` and `go.sum`. --- go.mod | 25 ++++++++- go.sum | 96 +++++++++++++++++++++++++++++++- internal/bifrost/cli_test.go | 32 +++++++++++ internal/bifrost/git_metadata.go | 61 ++++++++++++-------- internal/bifrost/sbom_upload.go | 13 +++-- 5 files changed, 196 insertions(+), 31 deletions(-) diff --git a/go.mod b/go.mod index 96954c8..ef54898 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,33 @@ module github.com/bifrostsec/bifrost-cli go 1.25.6 -require github.com/stretchr/testify v1.11.1 +require ( + github.com/go-git/go-git/v5 v5.19.1 + github.com/stretchr/testify v1.11.1 +) require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.9.0 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sys v0.43.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c4c1710..02a0890 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,104 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= +github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00= +github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= +github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/bifrost/cli_test.go b/internal/bifrost/cli_test.go index e357b4f..09134ab 100644 --- a/internal/bifrost/cli_test.go +++ b/internal/bifrost/cli_test.go @@ -306,6 +306,38 @@ func TestCLI_ValidCommandOutsideGitRepoOmitsGitMetadata(t *testing.T) { assert.Equal(t, 0, exitCode) } +func TestCLI_ValidCommandWithAutoGitMetadataOutsideGitRepoPrintsError(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", + "--enable-auto-git-metadata", + "sbom", "upload", path, + } + + exitCode, stderr := captureStderr(t, func() int { + return CLI("1.0", "commit", args) + }) + assert.Equal(t, 0, exitCode) + assertAutoDetectedGitMetadata(t, stderr, ".", "", "", "") + assert.Contains(t, stderr, " error=\"open git repository:") + 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")) diff --git a/internal/bifrost/git_metadata.go b/internal/bifrost/git_metadata.go index 5a10d78..eb1d22a 100644 --- a/internal/bifrost/git_metadata.go +++ b/internal/bifrost/git_metadata.go @@ -4,8 +4,9 @@ package bifrost import ( - "os/exec" - "strings" + "fmt" + + git "github.com/go-git/go-git/v5" ) type gitMetadata struct { @@ -14,34 +15,46 @@ type gitMetadata struct { origin string } -func discoverGitMetadata(dir string) gitMetadata { - insideWorkTree, ok := runGit(dir, "rev-parse", "--is-inside-work-tree") - if !ok || insideWorkTree != "true" { - return gitMetadata{} - } +type gitMetadataDiscovery struct { + metadata gitMetadata + errors []error +} - branch, _ := runGit(dir, "rev-parse", "--abbrev-ref", "HEAD") - if branch == "HEAD" { - branch = "" +func discoverGitMetadata(dir string) gitMetadataDiscovery { + repo, err := git.PlainOpenWithOptions(dir, &git.PlainOpenOptions{ + DetectDotGit: true, + EnableDotGitCommonDir: true, + }) + if err != nil { + return gitMetadataDiscovery{ + errors: []error{fmt.Errorf("open git repository: %w", err)}, + } } - commitSHA, _ := runGit(dir, "rev-parse", "HEAD") - origin, _ := runGit(dir, "config", "--get", "remote.origin.url") - metadata := gitMetadata{ - branch: branch, - commitSHA: commitSHA, - origin: origin, + metadata := gitMetadata{} + var errors []error + head, err := repo.Head() + if err == nil { + if head.Name().IsBranch() { + metadata.branch = head.Name().Short() + } + metadata.commitSHA = head.Hash().String() + } else { + errors = append(errors, fmt.Errorf("read HEAD: %w", err)) } - return metadata -} -func runGit(dir string, args ...string) (string, bool) { - gitArgs := append([]string{"-C", dir}, args...) - output, err := exec.Command("git", gitArgs...).Output() - if err != nil { - return "", false + cfg, err := repo.Config() + if err == nil { + if origin, ok := cfg.Remotes["origin"]; ok && len(origin.URLs) > 0 { + metadata.origin = origin.URLs[0] + } + } else { + errors = append(errors, fmt.Errorf("read git config: %w", err)) + } + return gitMetadataDiscovery{ + metadata: metadata, + errors: errors, } - return strings.TrimSpace(string(output)), true } func gitMetadataRepoPath(path string) string { diff --git a/internal/bifrost/sbom_upload.go b/internal/bifrost/sbom_upload.go index 70cc29b..4507c0c 100644 --- a/internal/bifrost/sbom_upload.go +++ b/internal/bifrost/sbom_upload.go @@ -43,7 +43,7 @@ func NewSBOMUploadTask(opts Options, args []string, cliVersion string) (Task, er if opts.enableAutoGitMetadata { autoDetectedGitMetadata := discoverGitMetadata(gitMetadataRepoPath(opts.gitRepoPath)) printAutoDetectedGitMetadata(opts.gitRepoPath, autoDetectedGitMetadata) - opts = withMissingGitMetadata(opts, autoDetectedGitMetadata) + opts = withMissingGitMetadata(opts, autoDetectedGitMetadata.metadata) } return &sbomUploadTask{ @@ -91,15 +91,18 @@ func (t sbomUploadTask) Run(ctx context.Context) error { return nil } -func printAutoDetectedGitMetadata(repoPath string, metadata gitMetadata) { +func printAutoDetectedGitMetadata(repoPath string, discovery gitMetadataDiscovery) { _, _ = fmt.Fprintf( os.Stderr, autoDetectedGitMetadataMessage, gitMetadataRepoPath(repoPath), - metadata.branch, - metadata.commitSHA, - metadata.origin, + discovery.metadata.branch, + discovery.metadata.commitSHA, + discovery.metadata.origin, ) + for _, err := range discovery.errors { + _, _ = fmt.Fprintf(os.Stderr, " error=%q\n", err) + } } func withMissingGitMetadata(opts Options, metadata gitMetadata) Options { From 0b3047d1ea8abe287926ae3f319a7aacbdd3df4d Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Tue, 7 Jul 2026 15:02:39 +0200 Subject: [PATCH 08/18] refactor: simplify Git metadata discovery using shell commands --- go.mod | 27 +-------- go.sum | 98 +------------------------------- internal/bifrost/cli_test.go | 39 ++++++++++++- internal/bifrost/git_metadata.go | 72 +++++++++++++++-------- internal/bifrost/sbom_upload.go | 6 +- 5 files changed, 92 insertions(+), 150 deletions(-) diff --git a/go.mod b/go.mod index ef54898..871b393 100644 --- a/go.mod +++ b/go.mod @@ -2,33 +2,10 @@ module github.com/bifrostsec/bifrost-cli go 1.25.6 -require ( - github.com/go-git/go-git/v5 v5.19.1 - github.com/stretchr/testify v1.11.1 -) +require github.com/stretchr/testify v1.11.1 require ( - dario.cat/mergo v1.0.0 // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProtonMail/go-crypto v1.1.6 // indirect - github.com/cloudflare/circl v1.6.3 // indirect - github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/emirpasic/gods v1.18.1 // indirect - github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.9.0 // indirect - github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect - github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/kevinburke/ssh_config v1.2.0 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect - github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect - github.com/skeema/knownhosts v1.3.1 // indirect - github.com/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/crypto v0.50.0 // indirect - golang.org/x/net v0.53.0 // indirect - golang.org/x/sys v0.43.0 // indirect - gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect -) +) \ No newline at end of file diff --git a/go.sum b/go.sum index 02a0890..4889dbd 100644 --- a/go.sum +++ b/go.sum @@ -1,104 +1,10 @@ -dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= -dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= -github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= -github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= -github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= -github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= -github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= -github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= -github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= -github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= -github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= -github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= -github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= -github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= -github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= -github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= -github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00= -github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= -github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= -github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= -github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= -github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= -github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= -github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= -github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= -github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= -golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= -gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= \ No newline at end of file diff --git a/internal/bifrost/cli_test.go b/internal/bifrost/cli_test.go index 09134ab..58bb951 100644 --- a/internal/bifrost/cli_test.go +++ b/internal/bifrost/cli_test.go @@ -157,6 +157,43 @@ func TestCLI_ValidCommandWithAutoGitMetadataFromGitRepoPath(t *testing.T) { assert.NotContains(t, stderr, missingGitMetadataHint) } +func TestCLI_ValidCommandWithAutoGitMetadataAndWorktreeConfigExtension(t *testing.T) { + branch := "feature/auto-git-metadata-worktree-config" + origin := "https://github.com/example/auto-project-worktree-config.git" + repoDir, commitSHA := createTestGitRepo(t, branch, origin) + runTestGit(t, repoDir, "config", "extensions.worktreeConfig", "true") + 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", + "--enable-auto-git-metadata", + "sbom", "upload", path, + } + + exitCode, stderr := captureStderr(t, func() int { + return CLI("1.0", "commit", args) + }) + assert.Equal(t, 0, exitCode) + assertAutoDetectedGitMetadata(t, stderr, ".", branch, commitSHA, origin) + assert.NotContains(t, stderr, " error=") + assert.NotContains(t, stderr, missingGitMetadataHint) +} + func TestCLI_ValidCommandInGitRepoWithoutAutoGitMetadataOmitsGitMetadata(t *testing.T) { branch := "feature/default-no-auto-git-metadata" origin := "https://github.com/example/default-no-auto-project.git" @@ -334,7 +371,7 @@ func TestCLI_ValidCommandWithAutoGitMetadataOutsideGitRepoPrintsError(t *testing }) assert.Equal(t, 0, exitCode) assertAutoDetectedGitMetadata(t, stderr, ".", "", "", "") - assert.Contains(t, stderr, " error=\"open git repository:") + assert.Contains(t, stderr, " error=\"check git work tree:") assert.Contains(t, stderr, missingGitMetadataHint) } diff --git a/internal/bifrost/git_metadata.go b/internal/bifrost/git_metadata.go index eb1d22a..cb1b239 100644 --- a/internal/bifrost/git_metadata.go +++ b/internal/bifrost/git_metadata.go @@ -5,8 +5,8 @@ package bifrost import ( "fmt" - - git "github.com/go-git/go-git/v5" + "os/exec" + "strings" ) type gitMetadata struct { @@ -21,45 +21,67 @@ type gitMetadataDiscovery struct { } func discoverGitMetadata(dir string) gitMetadataDiscovery { - repo, err := git.PlainOpenWithOptions(dir, &git.PlainOpenOptions{ - DetectDotGit: true, - EnableDotGitCommonDir: true, - }) + dir = repoPath(dir) + + // 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("open git repository: %w", err)}, + 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")}, } } - metadata := gitMetadata{} var errors []error - head, err := repo.Head() - if err == nil { - if head.Name().IsBranch() { - metadata.branch = head.Name().Short() + read := func(label string, args ...string) string { + value, err := runGitCommand(dir, args...) + if err != nil { + errors = append(errors, fmt.Errorf("%s: %w", label, err)) } - metadata.commitSHA = head.Hash().String() - } else { - errors = append(errors, fmt.Errorf("read HEAD: %w", err)) + return value } - cfg, err := repo.Config() - if err == nil { - if origin, ok := cfg.Remotes["origin"]; ok && len(origin.URLs) > 0 { - metadata.origin = origin.URLs[0] - } - } else { - errors = append(errors, fmt.Errorf("read git config: %w", err)) + // 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: metadata, - errors: errors, + metadata: gitMetadata{ + branch: branch, + commitSHA: commitSHA, + origin: origin, + }, + errors: errors, } } -func gitMetadataRepoPath(path string) string { +func repoPath(path string) string { if path == "" { return "." } return path } + +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 := "git " + 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/sbom_upload.go b/internal/bifrost/sbom_upload.go index 4507c0c..f33fa13 100644 --- a/internal/bifrost/sbom_upload.go +++ b/internal/bifrost/sbom_upload.go @@ -41,7 +41,7 @@ func NewSBOMUploadTask(opts Options, args []string, cliVersion string) (Task, er return nil, fmt.Errorf("at least one SBOM file path is required") } if opts.enableAutoGitMetadata { - autoDetectedGitMetadata := discoverGitMetadata(gitMetadataRepoPath(opts.gitRepoPath)) + autoDetectedGitMetadata := discoverGitMetadata(opts.gitRepoPath) printAutoDetectedGitMetadata(opts.gitRepoPath, autoDetectedGitMetadata) opts = withMissingGitMetadata(opts, autoDetectedGitMetadata.metadata) } @@ -91,11 +91,11 @@ func (t sbomUploadTask) Run(ctx context.Context) error { return nil } -func printAutoDetectedGitMetadata(repoPath string, discovery gitMetadataDiscovery) { +func printAutoDetectedGitMetadata(gitRepoPath string, discovery gitMetadataDiscovery) { _, _ = fmt.Fprintf( os.Stderr, autoDetectedGitMetadataMessage, - gitMetadataRepoPath(repoPath), + repoPath(gitRepoPath), discovery.metadata.branch, discovery.metadata.commitSHA, discovery.metadata.origin, From bb121fcd84e3e28874b237127a6dafbf574d8dc9 Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Tue, 7 Jul 2026 15:07:56 +0200 Subject: [PATCH 09/18] style: add an empty last line --- go.mod | 2 +- go.sum | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 871b393..96954c8 100644 --- a/go.mod +++ b/go.mod @@ -8,4 +8,4 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect -) \ No newline at end of file +) diff --git a/go.sum b/go.sum index 4889dbd..c4c1710 100644 --- a/go.sum +++ b/go.sum @@ -7,4 +7,4 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= \ No newline at end of file +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From c8078823cbe168c04f483f8e7004303e9d88f5a7 Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Tue, 7 Jul 2026 15:39:50 +0200 Subject: [PATCH 10/18] refactor: prefer explicitly set flag over env.var. --- internal/bifrost/cli.go | 2 +- internal/bifrost/options.go | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) 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/options.go b/internal/bifrost/options.go index 514936d..432a7d0 100644 --- a/internal/bifrost/options.go +++ b/internal/bifrost/options.go @@ -12,6 +12,8 @@ import ( "time" ) +const enableAutoGitMetadataFlag = "enable-auto-git-metadata" + type Options struct { ServerURL string apiKey string @@ -39,10 +41,10 @@ func RegisterOptions(fl *flag.FlagSet, opts *Options) { 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.enableAutoGitMetadata, "enable-auto-git-metadata", false, "Enable automatic Git metadata detection (or environment variable BIFROST_ENABLE_AUTO_GIT_METADATA=true)") + fl.BoolVar(&opts.enableAutoGitMetadata, enableAutoGitMetadataFlag, false, "Enable automatic Git metadata detection (or environment variable BIFROST_ENABLE_AUTO_GIT_METADATA=true)") } -func ValidateBaseOptions(opts *Options) error { +func ValidateBaseOptions(fl *flag.FlagSet, opts *Options) error { if u := os.Getenv("SERVER_URL"); u != "" { opts.ServerURL = u } @@ -66,7 +68,8 @@ func ValidateBaseOptions(opts *Options) error { if opts.retryDelay < 0 { return fmt.Errorf("retry delay must be zero or greater") } - if !opts.enableAutoGitMetadata { + // An explicitly passed flag takes precedence over the environment variable. + if !flagWasSet(fl, enableAutoGitMetadataFlag) { if value := os.Getenv("BIFROST_ENABLE_AUTO_GIT_METADATA"); value != "" { enableAutoGitMetadata, err := strconv.ParseBool(value) if err != nil { @@ -78,3 +81,13 @@ func ValidateBaseOptions(opts *Options) error { return nil } + +func flagWasSet(fl *flag.FlagSet, name string) bool { + set := false + fl.Visit(func(f *flag.Flag) { + if f.Name == name { + set = true + } + }) + return set +} From 3233f757e476a9f1c3294550f72d5828a04bd6c9 Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Tue, 7 Jul 2026 15:40:06 +0200 Subject: [PATCH 11/18] refactor: remove unused `repoPath` function --- internal/bifrost/git_metadata.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/internal/bifrost/git_metadata.go b/internal/bifrost/git_metadata.go index cb1b239..8295e19 100644 --- a/internal/bifrost/git_metadata.go +++ b/internal/bifrost/git_metadata.go @@ -21,8 +21,6 @@ type gitMetadataDiscovery struct { } func discoverGitMetadata(dir string) gitMetadataDiscovery { - dir = repoPath(dir) - // Verify that we are inside a Git repository. insideWorkingTree, err := runGitCommand(dir, "rev-parse", "--is-inside-work-tree") if err != nil { @@ -65,13 +63,6 @@ func discoverGitMetadata(dir string) gitMetadataDiscovery { } } -func repoPath(path string) string { - if path == "" { - return "." - } - return path -} - func runGitCommand(dir string, args ...string) (string, error) { gitArgs := append([]string{"-C", dir}, args...) output, err := exec.Command("git", gitArgs...).CombinedOutput() From 1a2ffd42caeaa5920a294ceeab0cb5423b0fdf85 Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Tue, 7 Jul 2026 15:40:23 +0200 Subject: [PATCH 12/18] refactor: simplify Git metadata logging by removing redundant `repoPath` function --- internal/bifrost/sbom_upload.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/bifrost/sbom_upload.go b/internal/bifrost/sbom_upload.go index f33fa13..111c4c4 100644 --- a/internal/bifrost/sbom_upload.go +++ b/internal/bifrost/sbom_upload.go @@ -95,7 +95,7 @@ func printAutoDetectedGitMetadata(gitRepoPath string, discovery gitMetadataDisco _, _ = fmt.Fprintf( os.Stderr, autoDetectedGitMetadataMessage, - repoPath(gitRepoPath), + gitRepoPath, discovery.metadata.branch, discovery.metadata.commitSHA, discovery.metadata.origin, From 4602389e4a4498ab1447047aa0167f8dca401252 Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Tue, 7 Jul 2026 15:40:33 +0200 Subject: [PATCH 13/18] refactor: remove redundant and outdated Git metadata tests - Deleted tests related to deprecated auto Git metadata functionality. - Simplified remaining tests to align with updated metadata handling. --- internal/bifrost/cli_test.go | 96 ++++-------------------------------- 1 file changed, 10 insertions(+), 86 deletions(-) diff --git a/internal/bifrost/cli_test.go b/internal/bifrost/cli_test.go index 58bb951..25eed5e 100644 --- a/internal/bifrost/cli_test.go +++ b/internal/bifrost/cli_test.go @@ -157,43 +157,6 @@ func TestCLI_ValidCommandWithAutoGitMetadataFromGitRepoPath(t *testing.T) { assert.NotContains(t, stderr, missingGitMetadataHint) } -func TestCLI_ValidCommandWithAutoGitMetadataAndWorktreeConfigExtension(t *testing.T) { - branch := "feature/auto-git-metadata-worktree-config" - origin := "https://github.com/example/auto-project-worktree-config.git" - repoDir, commitSHA := createTestGitRepo(t, branch, origin) - runTestGit(t, repoDir, "config", "extensions.worktreeConfig", "true") - 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", - "--enable-auto-git-metadata", - "sbom", "upload", path, - } - - exitCode, stderr := captureStderr(t, func() int { - return CLI("1.0", "commit", args) - }) - assert.Equal(t, 0, exitCode) - assertAutoDetectedGitMetadata(t, stderr, ".", branch, commitSHA, origin) - assert.NotContains(t, stderr, " error=") - assert.NotContains(t, stderr, missingGitMetadataHint) -} - func TestCLI_ValidCommandInGitRepoWithoutAutoGitMetadataOmitsGitMetadata(t *testing.T) { branch := "feature/default-no-auto-git-metadata" origin := "https://github.com/example/default-no-auto-project.git" @@ -222,9 +185,10 @@ func TestCLI_ValidCommandInGitRepoWithoutAutoGitMetadataOmitsGitMetadata(t *test assert.Equal(t, 0, exitCode) } -func TestCLI_ValidCommandWithAutoGitMetadataExplicitlyDisabledByFlagPrintsHint(t *testing.T) { - branch := "feature/flag-disabled-auto-git-metadata" - origin := "https://github.com/example/flag-disabled-project.git" +func TestCLI_ExplicitAutoGitMetadataFlagOverridesEnvironment(t *testing.T) { + t.Setenv("BIFROST_ENABLE_AUTO_GIT_METADATA", "true") + branch := "feature/flag-overrides-env" + origin := "https://github.com/example/flag-overrides-env-project.git" repoDir, _ := createTestGitRepo(t, branch, origin) chdir(t, repoDir) @@ -254,36 +218,22 @@ func TestCLI_ValidCommandWithAutoGitMetadataExplicitlyDisabledByFlagPrintsHint(t assert.Contains(t, stderr, missingGitMetadataHint) } -func TestCLI_ValidCommandWithAutoGitMetadataExplicitlyDisabledByEnvironmentPrintsHint(t *testing.T) { - t.Setenv("BIFROST_ENABLE_AUTO_GIT_METADATA", "false") - branch := "feature/env-disabled-auto-git-metadata" - origin := "https://github.com/example/env-disabled-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) +func TestCLI_InvalidAutoGitMetadataEnvironmentValue(t *testing.T) { + t.Setenv("BIFROST_ENABLE_AUTO_GIT_METADATA", "notabool") args := []string{ - fmt.Sprintf("--server-url=%s", httpServer.URL), + "--server-url=https://portal.bifrostsec.com", "--service=test-service", "--service-version=1.0", "--api-key=test-token", - "sbom", "upload", path, + "sbom", "upload", "test-sbom.json", } exitCode, stderr := captureStderr(t, func() int { return CLI("1.0", "commit", args) }) - assert.Equal(t, 0, exitCode) - assert.Contains(t, stderr, missingGitMetadataHint) + assert.Equal(t, 2, exitCode) + assert.Contains(t, stderr, "BIFROST_ENABLE_AUTO_GIT_METADATA must be a boolean") } func TestCLI_ValidCommandWithAutoGitMetadataEnabledByEnvironment(t *testing.T) { @@ -317,32 +267,6 @@ func TestCLI_ValidCommandWithAutoGitMetadataEnabledByEnvironment(t *testing.T) { assert.Equal(t, 0, exitCode) } -func TestCLI_ValidCommandOutsideGitRepoOmitsGitMetadata(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", - "sbom", "upload", path, - } - - exitCode := CLI("1.0", "commit", args) - assert.Equal(t, 0, exitCode) -} - func TestCLI_ValidCommandWithAutoGitMetadataOutsideGitRepoPrintsError(t *testing.T) { tempDir := t.TempDir() chdir(t, tempDir) From 9dedabdedf26e47aad01a30538b94fca65cf276e Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Tue, 7 Jul 2026 16:21:15 +0200 Subject: [PATCH 14/18] refactor: rename `--enable-auto-git-metadata` to `--git-auto-detect` --- README.md | 10 ++++----- internal/bifrost/cli_test.go | 30 +++++++++++++------------- internal/bifrost/options.go | 38 ++++++++++++++++----------------- internal/bifrost/sbom_upload.go | 4 ++-- 4 files changed, 41 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 7cbf7f2..18b3f2c 100644 --- a/README.md +++ b/README.md @@ -122,19 +122,19 @@ You can also enable automatic Git metadata detection. When enabled, bifrost fill current Git repository when those values are available: ```bash -./bifrost --service=my-service --service-version=1.2.3 --enable-auto-git-metadata sbom upload /path/to/sbom.json +./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 --enable-auto-git-metadata --git-repo-path=/path/to/repo sbom upload /path/to/sbom.json +./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_ENABLE_AUTO_GIT_METADATA=true` environment variable -- The `--enable-auto-git-metadata` flag +- 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: @@ -167,7 +167,7 @@ gh api \ | `--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. | -| `--enable-auto-git-metadata` | No | `BIFROST_ENABLE_AUTO_GIT_METADATA` | Automatically fill missing Git metadata from the current Git repository when available. | +| `--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/cli_test.go b/internal/bifrost/cli_test.go index 25eed5e..bab774d 100644 --- a/internal/bifrost/cli_test.go +++ b/internal/bifrost/cli_test.go @@ -86,7 +86,7 @@ func TestCLI_ValidCommandWithGitMetadata(t *testing.T) { assert.NotContains(t, stderr, missingGitMetadataHint) } -func TestCLI_ValidCommandWithAutoGitMetadataEnabledByFlag(t *testing.T) { +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) @@ -109,7 +109,7 @@ func TestCLI_ValidCommandWithAutoGitMetadataEnabledByFlag(t *testing.T) { "--service=test-service", "--service-version=1.0", "--api-key=test-token", - "--enable-auto-git-metadata", + "--git-auto-detect", "sbom", "upload", path, } @@ -121,7 +121,7 @@ func TestCLI_ValidCommandWithAutoGitMetadataEnabledByFlag(t *testing.T) { assert.NotContains(t, stderr, missingGitMetadataHint) } -func TestCLI_ValidCommandWithAutoGitMetadataFromGitRepoPath(t *testing.T) { +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) @@ -144,7 +144,7 @@ func TestCLI_ValidCommandWithAutoGitMetadataFromGitRepoPath(t *testing.T) { "--service=test-service", "--service-version=1.0", "--api-key=test-token", - "--enable-auto-git-metadata", + "--git-auto-detect", fmt.Sprintf("--git-repo-path=%s", repoDir), "sbom", "upload", path, } @@ -157,7 +157,7 @@ func TestCLI_ValidCommandWithAutoGitMetadataFromGitRepoPath(t *testing.T) { assert.NotContains(t, stderr, missingGitMetadataHint) } -func TestCLI_ValidCommandInGitRepoWithoutAutoGitMetadataOmitsGitMetadata(t *testing.T) { +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) @@ -185,8 +185,8 @@ func TestCLI_ValidCommandInGitRepoWithoutAutoGitMetadataOmitsGitMetadata(t *test assert.Equal(t, 0, exitCode) } -func TestCLI_ExplicitAutoGitMetadataFlagOverridesEnvironment(t *testing.T) { - t.Setenv("BIFROST_ENABLE_AUTO_GIT_METADATA", "true") +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) @@ -207,7 +207,7 @@ func TestCLI_ExplicitAutoGitMetadataFlagOverridesEnvironment(t *testing.T) { "--service=test-service", "--service-version=1.0", "--api-key=test-token", - "--enable-auto-git-metadata=false", + "--git-auto-detect=false", "sbom", "upload", path, } @@ -218,8 +218,8 @@ func TestCLI_ExplicitAutoGitMetadataFlagOverridesEnvironment(t *testing.T) { assert.Contains(t, stderr, missingGitMetadataHint) } -func TestCLI_InvalidAutoGitMetadataEnvironmentValue(t *testing.T) { - t.Setenv("BIFROST_ENABLE_AUTO_GIT_METADATA", "notabool") +func TestCLI_InvalidGitAutoDetectEnvironmentValue(t *testing.T) { + t.Setenv("BIFROST_GIT_AUTO_DETECT", "notabool") args := []string{ "--server-url=https://portal.bifrostsec.com", @@ -233,11 +233,11 @@ func TestCLI_InvalidAutoGitMetadataEnvironmentValue(t *testing.T) { return CLI("1.0", "commit", args) }) assert.Equal(t, 2, exitCode) - assert.Contains(t, stderr, "BIFROST_ENABLE_AUTO_GIT_METADATA must be a boolean") + assert.Contains(t, stderr, "BIFROST_GIT_AUTO_DETECT must be a boolean") } -func TestCLI_ValidCommandWithAutoGitMetadataEnabledByEnvironment(t *testing.T) { - t.Setenv("BIFROST_ENABLE_AUTO_GIT_METADATA", "true") +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) @@ -267,7 +267,7 @@ func TestCLI_ValidCommandWithAutoGitMetadataEnabledByEnvironment(t *testing.T) { assert.Equal(t, 0, exitCode) } -func TestCLI_ValidCommandWithAutoGitMetadataOutsideGitRepoPrintsError(t *testing.T) { +func TestCLI_ValidCommandWithGitAutoDetectOutsideGitRepoPrintsError(t *testing.T) { tempDir := t.TempDir() chdir(t, tempDir) @@ -286,7 +286,7 @@ func TestCLI_ValidCommandWithAutoGitMetadataOutsideGitRepoPrintsError(t *testing "--service=test-service", "--service-version=1.0", "--api-key=test-token", - "--enable-auto-git-metadata", + "--git-auto-detect", "sbom", "upload", path, } diff --git a/internal/bifrost/options.go b/internal/bifrost/options.go index 432a7d0..a483f69 100644 --- a/internal/bifrost/options.go +++ b/internal/bifrost/options.go @@ -12,21 +12,21 @@ import ( "time" ) -const enableAutoGitMetadataFlag = "enable-auto-git-metadata" +const gitAutoDetectFlag = "git-auto-detect" type Options struct { - ServerURL string - apiKey string - service string - serviceVersion string - image string - retryAttempts int - retryDelay time.Duration - gitBranch string - gitCommitSHA string - gitOrigin string - gitRepoPath string - enableAutoGitMetadata bool + ServerURL string + apiKey string + service string + serviceVersion string + image string + retryAttempts int + retryDelay time.Duration + gitBranch string + gitCommitSHA string + gitOrigin string + gitRepoPath string + gitAutoDetect bool } func RegisterOptions(fl *flag.FlagSet, opts *Options) { @@ -41,7 +41,7 @@ func RegisterOptions(fl *flag.FlagSet, opts *Options) { 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.enableAutoGitMetadata, enableAutoGitMetadataFlag, false, "Enable automatic Git metadata detection (or environment variable BIFROST_ENABLE_AUTO_GIT_METADATA=true)") + fl.BoolVar(&opts.gitAutoDetect, gitAutoDetectFlag, false, "Automatically detect Git metadata (or environment variable BIFROST_GIT_AUTO_DETECT=true)") } func ValidateBaseOptions(fl *flag.FlagSet, opts *Options) error { @@ -69,13 +69,13 @@ func ValidateBaseOptions(fl *flag.FlagSet, opts *Options) error { return fmt.Errorf("retry delay must be zero or greater") } // An explicitly passed flag takes precedence over the environment variable. - if !flagWasSet(fl, enableAutoGitMetadataFlag) { - if value := os.Getenv("BIFROST_ENABLE_AUTO_GIT_METADATA"); value != "" { - enableAutoGitMetadata, err := strconv.ParseBool(value) + if !flagWasSet(fl, gitAutoDetectFlag) { + if value := os.Getenv("BIFROST_GIT_AUTO_DETECT"); value != "" { + gitAutoDetect, err := strconv.ParseBool(value) if err != nil { - return fmt.Errorf("BIFROST_ENABLE_AUTO_GIT_METADATA must be a boolean") + return fmt.Errorf("BIFROST_GIT_AUTO_DETECT must be a boolean") } - opts.enableAutoGitMetadata = enableAutoGitMetadata + opts.gitAutoDetect = gitAutoDetect } } diff --git a/internal/bifrost/sbom_upload.go b/internal/bifrost/sbom_upload.go index 111c4c4..947c283 100644 --- a/internal/bifrost/sbom_upload.go +++ b/internal/bifrost/sbom_upload.go @@ -16,7 +16,7 @@ type sbomUploadTask struct { cliVersion string } -const missingGitMetadataHint = "Hint: no Git metadata was provided. To automatically attach Git metadata, run from a Git repository with --enable-auto-git-metadata or set the BIFROST_ENABLE_AUTO_GIT_METADATA=true environment variable. Use --git-repo-path when the repository is elsewhere.\n" +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 autoDetectedGitMetadataMessage = "Auto-detected Git metadata 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) { @@ -40,7 +40,7 @@ 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.enableAutoGitMetadata { + if opts.gitAutoDetect { autoDetectedGitMetadata := discoverGitMetadata(opts.gitRepoPath) printAutoDetectedGitMetadata(opts.gitRepoPath, autoDetectedGitMetadata) opts = withMissingGitMetadata(opts, autoDetectedGitMetadata.metadata) From 80572595dad2634f49bc5f994ee4a200019ee80c Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Tue, 7 Jul 2026 16:40:04 +0200 Subject: [PATCH 15/18] fix: address review comments --- internal/bifrost/cli_test.go | 44 +++++++++++++++++++++++++++----- internal/bifrost/git_metadata.go | 2 +- internal/bifrost/sbom_upload.go | 12 ++++----- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/internal/bifrost/cli_test.go b/internal/bifrost/cli_test.go index bab774d..5063c37 100644 --- a/internal/bifrost/cli_test.go +++ b/internal/bifrost/cli_test.go @@ -117,7 +117,7 @@ func TestCLI_ValidCommandWithGitAutoDetectEnabledByFlag(t *testing.T) { return CLI("1.0", "commit", args) }) assert.Equal(t, 0, exitCode) - assertAutoDetectedGitMetadata(t, stderr, ".", branch, commitSHA, origin) + assertGitMetadataDetection(t, stderr, ".", branch, commitSHA, origin) assert.NotContains(t, stderr, missingGitMetadataHint) } @@ -153,7 +153,7 @@ func TestCLI_ValidCommandWithGitAutoDetectFromGitRepoPath(t *testing.T) { return CLI("1.0", "commit", args) }) assert.Equal(t, 0, exitCode) - assertAutoDetectedGitMetadata(t, stderr, repoDir, branch, commitSHA, origin) + assertGitMetadataDetection(t, stderr, repoDir, branch, commitSHA, origin) assert.NotContains(t, stderr, missingGitMetadataHint) } @@ -294,8 +294,9 @@ func TestCLI_ValidCommandWithGitAutoDetectOutsideGitRepoPrintsError(t *testing.T return CLI("1.0", "commit", args) }) assert.Equal(t, 0, exitCode) - assertAutoDetectedGitMetadata(t, stderr, ".", "", "", "") + 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) } @@ -534,10 +535,10 @@ func assertNoGitMetadataQuery(t *testing.T, r *http.Request) { assert.False(t, hasGitOrigin) } -func assertAutoDetectedGitMetadata(t *testing.T, stderr string, repoPath string, branch string, commitSHA string, origin string) { +func assertGitMetadataDetection(t *testing.T, stderr string, repoPath string, branch string, commitSHA string, origin string) { t.Helper() - assert.Contains(t, stderr, fmt.Sprintf("Auto-detected Git metadata from %s:\n", repoPath)) + 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)) @@ -556,10 +557,20 @@ func captureStderr(t *testing.T, run func() int) (int, string) { originalStderr := os.Stderr os.Stderr = writePipe + writePipeClosed := false + defer func() { + os.Stderr = originalStderr + if !writePipeClosed { + _ = writePipe.Close() + } + }() + exitCode := run() os.Stderr = originalStderr - if err := writePipe.Close(); err != nil { + err = writePipe.Close() + writePipeClosed = true + if err != nil { t.Fatalf("failed to close stderr pipe: %v", err) } @@ -570,6 +581,27 @@ func captureStderr(t *testing.T, run func() int) (int, string) { 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 index 8295e19..cd9d65f 100644 --- a/internal/bifrost/git_metadata.go +++ b/internal/bifrost/git_metadata.go @@ -68,7 +68,7 @@ func runGitCommand(dir string, args ...string) (string, error) { output, err := exec.Command("git", gitArgs...).CombinedOutput() message := strings.TrimSpace(string(output)) if err != nil { - command := "git " + strings.Join(args, " ") + command := fmt.Sprintf("git -C %q %s", dir, strings.Join(args, " ")) if message == "" { return "", fmt.Errorf("%s: %w", command, err) } diff --git a/internal/bifrost/sbom_upload.go b/internal/bifrost/sbom_upload.go index 947c283..52004bc 100644 --- a/internal/bifrost/sbom_upload.go +++ b/internal/bifrost/sbom_upload.go @@ -17,7 +17,7 @@ type sbomUploadTask struct { } 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 autoDetectedGitMetadataMessage = "Auto-detected Git metadata from %s:\n git_branch=%q\n git_commit_sha=%q\n git_origin=%q\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 == "" { @@ -41,9 +41,9 @@ func NewSBOMUploadTask(opts Options, args []string, cliVersion string) (Task, er return nil, fmt.Errorf("at least one SBOM file path is required") } if opts.gitAutoDetect { - autoDetectedGitMetadata := discoverGitMetadata(opts.gitRepoPath) - printAutoDetectedGitMetadata(opts.gitRepoPath, autoDetectedGitMetadata) - opts = withMissingGitMetadata(opts, autoDetectedGitMetadata.metadata) + gitMetadataDetection := discoverGitMetadata(opts.gitRepoPath) + printGitMetadataDetection(opts.gitRepoPath, gitMetadataDetection) + opts = withMissingGitMetadata(opts, gitMetadataDetection.metadata) } return &sbomUploadTask{ @@ -91,10 +91,10 @@ func (t sbomUploadTask) Run(ctx context.Context) error { return nil } -func printAutoDetectedGitMetadata(gitRepoPath string, discovery gitMetadataDiscovery) { +func printGitMetadataDetection(gitRepoPath string, discovery gitMetadataDiscovery) { _, _ = fmt.Fprintf( os.Stderr, - autoDetectedGitMetadataMessage, + gitMetadataDetectionMessage, gitRepoPath, discovery.metadata.branch, discovery.metadata.commitSHA, From b01c9b273a0a38df14a2231128eac04d07bd95c9 Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Wed, 8 Jul 2026 08:52:47 +0200 Subject: [PATCH 16/18] refactor: rename `flagWasSet` to `isFlagSet` for clarity --- internal/bifrost/options.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/bifrost/options.go b/internal/bifrost/options.go index a483f69..102d1a8 100644 --- a/internal/bifrost/options.go +++ b/internal/bifrost/options.go @@ -69,7 +69,7 @@ func ValidateBaseOptions(fl *flag.FlagSet, opts *Options) error { return fmt.Errorf("retry delay must be zero or greater") } // An explicitly passed flag takes precedence over the environment variable. - if !flagWasSet(fl, gitAutoDetectFlag) { + if !isFlagSet(fl, gitAutoDetectFlag) { if value := os.Getenv("BIFROST_GIT_AUTO_DETECT"); value != "" { gitAutoDetect, err := strconv.ParseBool(value) if err != nil { @@ -82,12 +82,12 @@ func ValidateBaseOptions(fl *flag.FlagSet, opts *Options) error { return nil } -func flagWasSet(fl *flag.FlagSet, name string) bool { - set := false +func isFlagSet(fl *flag.FlagSet, name string) bool { + isSet := false fl.Visit(func(f *flag.Flag) { if f.Name == name { - set = true + isSet = true } }) - return set + return isSet } From 8ed5cf3fb3fc75a90eb3d6451cb1118176766a0f Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Wed, 8 Jul 2026 09:02:11 +0200 Subject: [PATCH 17/18] refactor: rename `withMissingGitMetadata` to `applyGitMetadataDetection` --- internal/bifrost/sbom_upload.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/bifrost/sbom_upload.go b/internal/bifrost/sbom_upload.go index 52004bc..3223951 100644 --- a/internal/bifrost/sbom_upload.go +++ b/internal/bifrost/sbom_upload.go @@ -43,7 +43,7 @@ func NewSBOMUploadTask(opts Options, args []string, cliVersion string) (Task, er if opts.gitAutoDetect { gitMetadataDetection := discoverGitMetadata(opts.gitRepoPath) printGitMetadataDetection(opts.gitRepoPath, gitMetadataDetection) - opts = withMissingGitMetadata(opts, gitMetadataDetection.metadata) + opts = applyGitMetadataDetection(opts, gitMetadataDetection.metadata) } return &sbomUploadTask{ @@ -105,7 +105,7 @@ func printGitMetadataDetection(gitRepoPath string, discovery gitMetadataDiscover } } -func withMissingGitMetadata(opts Options, metadata gitMetadata) Options { +func applyGitMetadataDetection(opts Options, metadata gitMetadata) Options { if opts.gitBranch == "" { opts.gitBranch = metadata.branch } From c3511182125ff73266225e0359c6fc2677be2cb8 Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Wed, 8 Jul 2026 09:03:10 +0200 Subject: [PATCH 18/18] style: remove extra newline in README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 18b3f2c..21ca167 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,6 @@ 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/)