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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ archives:
- goos: windows
format: zip

checksum:
name_template: "checksums.txt"
algorithm: sha256

changelog:
sort: asc
filters:
Expand Down
4 changes: 4 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

Bruin is currently running a single major version, v0, which will continue receiving security updates as we go.

## Binary Integrity

Bruin verifies downloaded gong helper binaries with SHA256 checksums before installation. If the checksum is missing or does not match the downloaded file, installation stops and the downloaded file is discarded.

## Reporting a Vulnerability

Please report security vulnerabilities via the email address `security@getbruin.com`
75 changes: 75 additions & 0 deletions pkg/gong/checksum.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package gong

import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"strings"
)

// parseChecksumManifest parses a sha256sum-format checksum file and returns
// the expected SHA256 hex string for the given artifactName.
//
// The expected file format is one entry per line:
//
// <sha256hex> <filename>
//
// Empty lines and lines starting with '#' are ignored.
func parseChecksumManifest(contents []byte, artifactName string) (string, error) {
lines := strings.Split(string(contents), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
checksum := parts[0]
filename := parts[1]
if filename != artifactName {
continue
}
if !isValidSHA256(checksum) {
return "", fmt.Errorf("invalid checksum for %s", artifactName)
}
return checksum, nil
}
return "", fmt.Errorf("checksum entry not found for %s", artifactName)
}

// verifySHA256 computes the SHA256 digest of the file at path and compares it
// against the expected hex string. The comparison is case-insensitive so both
// uppercase and lowercase checksums are accepted.
func verifySHA256(path string, expected string) error {
if !isValidSHA256(expected) {
return fmt.Errorf("invalid expected checksum")
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return err
}
actual := hex.EncodeToString(h.Sum(nil))
if !strings.EqualFold(actual, expected) {
return fmt.Errorf("checksum mismatch: expected %s, got %s", expected, actual)
}
return nil
}

// isValidSHA256 reports whether value is a valid SHA256 hex digest:
// exactly 64 hexadecimal characters (case-insensitive).
func isValidSHA256(value string) bool {
if len(value) != 64 {
return false
}
_, err := hex.DecodeString(value)
return err == nil
}
247 changes: 247 additions & 0 deletions pkg/gong/checksum_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
package gong

import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// ─── shared test fixtures ────────────────────────────────────────────────────

// knownHash is the real SHA256 of the string "test".
// We hardcode it so test expectations are trivially auditable without running
// any hash code in the test setup itself.
const knownHash = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"

// hashOf returns the lowercase hex SHA256 of b.
// Used to build expected values for verifySHA256 tests without
// duplicating the hash algorithm.
func hashOf(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}

// tempFileWithContent writes content to a new temp file inside t.TempDir()
// and returns the file path. Cleanup is handled automatically by the test runner.
func tempFileWithContent(t *testing.T, content []byte) string {
t.Helper()

f, err := os.CreateTemp(t.TempDir(), "verify-sha256-*")
require.NoError(t, err)
_, err = f.Write(content)
require.NoError(t, err)
require.NoError(t, f.Close())
return f.Name()
}

// ─── parseChecksumManifest ───────────────────────────────────────────────────

func TestParseChecksumManifestReturnsMatchingChecksum(t *testing.T) {
t.Parallel()

artifactName := "gong_linux_amd64"
manifest := []byte(knownHash + " " + artifactName + "\n")

got, err := parseChecksumManifest(manifest, artifactName)

require.NoError(t, err)
assert.Equal(t, knownHash, got)
}

func TestParseChecksumManifestIgnoresEmptyAndCommentLines(t *testing.T) {
t.Parallel()

artifactName := "gong_linux_amd64"
// Manifest deliberately padded with every kind of noise the spec calls out:
// blank lines, lines of only whitespace, and comment lines.
manifest := []byte(fmt.Sprintf(
"# generated by goreleaser\n"+
"\n"+
" \n"+
"# sha256\n"+
"%s %s\n"+
"\n"+
"# end\n",
knownHash, artifactName,
))

got, err := parseChecksumManifest(manifest, artifactName)

require.NoError(t, err)
assert.Equal(t, knownHash, got)
}

// TestParseChecksumManifestPicksCorrectEntryAmongMultiple verifies that when a
// manifest contains several valid entries the parser returns the checksum for
// the requested artifact only — not for any other artifact.
func TestParseChecksumManifestPicksCorrectEntryAmongMultiple(t *testing.T) {
t.Parallel()

arm64Hash := strings.Repeat("a", 64)
amd64Hash := knownHash
manifest := []byte(fmt.Sprintf(
"%s gong_linux_arm64\n"+
"%s gong_linux_amd64\n",
arm64Hash, amd64Hash,
))

got, err := parseChecksumManifest(manifest, "gong_linux_amd64")

require.NoError(t, err)
assert.Equal(t, amd64Hash, got)
}

func TestParseChecksumManifestReturnsErrorWhenArtifactMissing(t *testing.T) {
t.Parallel()

// Manifest contains only arm64; requesting amd64 must fail.
manifest := []byte(knownHash + " gong_linux_arm64\n")

_, err := parseChecksumManifest(manifest, "gong_linux_amd64")

require.Error(t, err)
assert.Contains(t, err.Error(), "checksum entry not found for gong_linux_amd64")
}

// TestParseChecksumManifestReturnsErrorForMalformedLine covers the case where
// the manifest holds a line with only one whitespace-separated token (no
// checksum column). The parser skips such lines — because a corrupt entry for
// an unrelated artifact must not block a valid one — so the artifact is never
// matched and "checksum entry not found" is returned. The caller therefore
// always receives an error rather than a silent empty string.
func TestParseChecksumManifestReturnsErrorForMalformedLine(t *testing.T) {
t.Parallel()

// Single-token line: the artifact name appears but with no checksum column.
manifest := []byte("gong_linux_amd64\n")

_, err := parseChecksumManifest(manifest, "gong_linux_amd64")

require.Error(t, err)
assert.Contains(t, err.Error(), "checksum entry not found for gong_linux_amd64")
}

// TestParseChecksumManifestReturnsErrorForInvalidChecksum covers a line that is
// structurally valid (two fields) but whose checksum column contains non-hex
// characters. This is distinct from a malformed line: the artifact IS found,
// but isValidSHA256 rejects the value before we return it.
func TestParseChecksumManifestReturnsErrorForInvalidChecksum(t *testing.T) {
t.Parallel()

// 64 characters but all 'Z' — valid length, invalid hex.
invalidChecksum := strings.Repeat("Z", 64)
artifactName := "gong_linux_amd64"
manifest := []byte(invalidChecksum + " " + artifactName + "\n")

_, err := parseChecksumManifest(manifest, artifactName)

require.Error(t, err)
assert.Contains(t, err.Error(), "invalid checksum for gong_linux_amd64")
}

// ─── verifySHA256 ────────────────────────────────────────────────────────────

func TestVerifySHA256ReturnsNilForMatchingChecksum(t *testing.T) {
t.Parallel()

content := []byte("fake gong binary content")
path := tempFileWithContent(t, content)
expected := hashOf(content)

err := verifySHA256(path, expected)

assert.NoError(t, err)
}

func TestVerifySHA256ReturnsErrorForMismatch(t *testing.T) {
t.Parallel()

content := []byte("fake gong binary content")
path := tempFileWithContent(t, content)
// Checksum of different content — guaranteed not to match.
wrongHash := hashOf([]byte("completely different content"))

err := verifySHA256(path, wrongHash)

require.Error(t, err)
// Error must name both hashes so the operator can diagnose without
// running external tools.
assert.Contains(t, err.Error(), "checksum mismatch")
assert.Contains(t, err.Error(), hashOf(content)) // actual
assert.Contains(t, err.Error(), wrongHash) // expected
}

// TestVerifySHA256AcceptsUppercaseChecksum ensures the comparison is
// case-insensitive, which is important because GoReleaser emits lowercase
// hashes but users may paste uppercase values from other tools.
func TestVerifySHA256AcceptsUppercaseChecksum(t *testing.T) {
t.Parallel()

content := []byte("fake gong binary content")
path := tempFileWithContent(t, content)
upper := strings.ToUpper(hashOf(content))

err := verifySHA256(path, upper)

assert.NoError(t, err)
}

// TestVerifySHA256ReturnsErrorForNonExistentFile ensures the function surfaces
// the OS error when the target file is absent, so callers get a clear signal
// instead of a hash-mismatch error.
func TestVerifySHA256ReturnsErrorForNonExistentFile(t *testing.T) {
t.Parallel()

err := verifySHA256(t.TempDir()+"/does-not-exist", knownHash)

require.Error(t, err)
assert.True(t, os.IsNotExist(err), "expected a not-exist error, got: %v", err)
}

// TestVerifySHA256ReturnsErrorForInvalidExpectedChecksum verifies that the
// guard inside verifySHA256 fires before any file I/O when the caller passes
// a structurally invalid expected value.
func TestVerifySHA256ReturnsErrorForInvalidExpectedChecksum(t *testing.T) {
t.Parallel()

path := tempFileWithContent(t, []byte("anything"))

err := verifySHA256(path, "tooshort")

require.Error(t, err)
assert.Contains(t, err.Error(), "invalid expected checksum")
}

// ─── isValidSHA256 ───────────────────────────────────────────────────────────

func TestIsValidSHA256(t *testing.T) {
t.Parallel()

cases := []struct {
name string
input string
want bool
}{
{"lowercase valid", knownHash, true},
{"uppercase valid", strings.ToUpper(knownHash), true},
{"mixed case valid", strings.ToUpper(knownHash[:32]) + knownHash[32:], true},
{"too short", knownHash[:63], false},
{"too long", knownHash + "0", false},
{"empty string", "", false},
{"non-hex chars", strings.Repeat("Z", 64), false},
{"spaces inside", strings.Repeat("a", 32) + " " + strings.Repeat("b", 31), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

assert.Equal(t, tc.want, isValidSHA256(tc.input))
})
}
}
Loading