Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
88fd654
refactor(labels): replace raw gordon.* strings with domain constants
bnema Feb 24, 2026
1d7ad15
fix(labels): write RFC3339 timestamp for gordon.created label instead…
bnema Feb 24, 2026
378e6ae
fix(auth): gordon auth internal reads stale creds when XDG_RUNTIME_DI…
bnema Feb 24, 2026
ef13abe
refactor(http): consolidate isLocalhostRequest into shared httputil p…
bnema Feb 24, 2026
fea31c2
fix(push): inject GIT_TAG, GIT_SHA, BUILD_TIME as explicit build args…
bnema Feb 24, 2026
65a3cda
refactor(auth): remove dead RegistryAuth middleware and deprecated Ge…
bnema Feb 24, 2026
2b6f699
fix(config): sync external_routes and network_groups to viper before …
bnema Feb 24, 2026
180fc57
fix(push): warn when git has no tags and version falls back to 'latest'
bnema Feb 24, 2026
fb4d5cb
fix(container): rebuild attachment map in SyncContainers to prevent o…
bnema Feb 24, 2026
e11d53a
fix(container): propagate attachment config changes to container serv…
bnema Feb 24, 2026
7ac7d41
feat(auth): slide token expiry +24h on each CLI request with 1h debounce
bnema Feb 24, 2026
c9c277f
fix(review): address CodeRabbit findings from PR review
bnema Feb 25, 2026
4d5c9a7
chore(mocks): regenerate mocks with mockery
bnema Feb 25, 2026
2af0379
fix(auth): reject tokens with future iat to prevent ephemeral classif…
bnema Feb 25, 2026
72f1584
fix(app): include TLS server in graceful shutdown to drain active HTT…
bnema Feb 25, 2026
c7c3156
fix(cli): log panic in token refresh callback instead of silently dis…
bnema Feb 25, 2026
bcb8bd6
fix(tokenstore): add mutex to UnsafeStore.Revoke to prevent concurren…
bnema Feb 25, 2026
c2390ba
fix(tokenstore): correctly enumerate pass tokens with / in subject path
bnema Feb 25, 2026
b20fdb2
fix(tokenstore): use frame() conversion instead of struct literal (st…
bnema Feb 25, 2026
2ea91ef
fix(lint): G115 safe stdin fd conversion, QF1012 use fmt.Fprintf dire…
bnema Feb 25, 2026
cdae3fa
fix(lint): G117 annotate intentional secret-pattern fields in DTOs an…
bnema Feb 25, 2026
0ba63e8
fix(lint): G703/G704/G705 annotate taint analysis false positives wit…
bnema Feb 25, 2026
31f643d
Revert "fix(lint): G117 annotate intentional secret-pattern fields in…
bnema Feb 25, 2026
df677c2
fix(lint): centralise gosec G117/G703/G704/G705 exclusions in config,…
bnema Feb 25, 2026
2473f94
fix(review): address code review findings — correctness, robustness, …
bnema Feb 25, 2026
070a2bd
fix(review): context timeouts, defer cancel, skip malformed tree lines
bnema Feb 25, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ jobs:
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: latest
version: v2.10.1

test:
name: Test
Expand Down
20 changes: 20 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,26 @@ linters:
- text: "G104:"
linters:
- gosec
# gosec G117: exported struct fields matching secret-name patterns are intentional DTOs
# (OCI registry tokens, CLI credential request bodies, telemetry config, JWT response frames).
# These fields must be exported and carry credentials by design — they are not accidental leaks.
- text: "G117:"
linters:
- gosec
# gosec G703/G704/G705: taint-analysis false positives for a CLI tool and reverse proxy.
# G704 (SSRF): target URLs come from operator-configured routing tables or explicit CLI flags,
# not from untrusted user input. G703 (path traversal): XDG_RUNTIME_DIR is a trusted env var
# set by the login session, not user-supplied. G705 (XSS): registry manifest data is binary
# OCI content served with an explicit Content-Type, not rendered as HTML.
- text: "G703:"
linters:
- gosec
- text: "G704:"
linters:
- gosec
- text: "G705:"
linters:
- gosec
Comment on lines +61 to +80
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Scope gosec suppressions; avoid repository-wide rule-ID ignores.

Line 61 through Line 80 add global text-based ignores for G117/G703/G704/G705. This suppresses those checks across the entire repo and can silently mask future real vulnerabilities outside the currently-justified call sites.

Use path-scoped exclusions (for narrowly known files) or inline #nosec with justification at exact lines instead of disabling whole IDs globally.

Proposed config tightening
-      # gosec G117: exported struct fields matching secret-name patterns are intentional DTOs
-      # (OCI registry tokens, CLI credential request bodies, telemetry config, JWT response frames).
-      # These fields must be exported and carry credentials by design — they are not accidental leaks.
-      - text: "G117:"
-        linters:
-          - gosec
-      # gosec G703/G704/G705: taint-analysis false positives for a CLI tool and reverse proxy.
-      # G704 (SSRF): target URLs come from operator-configured routing tables or explicit CLI flags,
-      # not from untrusted user input. G703 (path traversal): XDG_RUNTIME_DIR is a trusted env var
-      # set by the login session, not user-supplied. G705 (XSS): registry manifest data is binary
-      # OCI content served with an explicit Content-Type, not rendered as HTML.
-      - text: "G703:"
-        linters:
-          - gosec
-      - text: "G704:"
-        linters:
-          - gosec
-      - text: "G705:"
-        linters:
-          - gosec
+      # Keep gosec findings visible globally.
+      # Suppress only specific known false positives via:
+      # 1) path-scoped exclusions for exact packages/files, or
+      # 2) inline `#nosec Gxxx -- justification` at the precise line.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.golangci.yml around lines 61 - 80, The current .golangci.yml adds repo-wide
suppressions for gosec rule IDs G117, G703, G704, and G705 (the blocks with
text: "G117:", "G703:", "G704:", "G705:"), which is too broad; instead, replace
these global text-based ignores with path-scoped exclusions targeting only the
specific files that legitimately require them (or remove the entries and add
inline `#nosec` comments at the exact lines in those files with a brief
justification), ensuring each suppression references the specific file(s) and
rationale rather than disabling the rule across the whole repository.


formatters:
enable:
Expand Down
2 changes: 1 addition & 1 deletion internal/adapters/dto/registry_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package dto
// TokenResponse represents the response from the token server.
type TokenResponse struct {
Token string `json:"token"`
AccessToken string `json:"access_token,omitempty"`
AccessToken string `json:"access_token,omitempty"` //nolint:gosec // OCI token response field, required by Docker Registry API v2 spec
ExpiresIn int `json:"expires_in,omitempty"`
IssuedAt string `json:"issued_at,omitempty"`
}
22 changes: 20 additions & 2 deletions internal/adapters/in/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ import (
"github.com/bnema/gordon/pkg/duration"
)

// stdinFD returns os.Stdin's file descriptor as an int, guarded against
// uintptr overflow on platforms where uintptr > int.
func stdinFD() (int, error) {
fd := os.Stdin.Fd()
if fd > uintptr(^uint(0)>>1) {
return 0, fmt.Errorf("stdin fd value %d overflows int", fd)
}
return int(fd), nil
}

// newAuthCmd creates the auth command group.
func newAuthCmd() *cobra.Command {
cmd := &cobra.Command{
Expand Down Expand Up @@ -274,7 +284,11 @@ func runAuthLogin(remoteName, username, password string) error {
if password == "" {
// Prompt for password (hidden input)
fmt.Print("Password: ")
passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
fd, err := stdinFD()
if err != nil {
return fmt.Errorf("failed to get stdin fd: %w", err)
}
passwordBytes, err := term.ReadPassword(fd)
if err != nil {
// Fallback for non-terminal input
reader := bufio.NewReader(os.Stdin)
Expand Down Expand Up @@ -595,7 +609,11 @@ func runPasswordHash() error {
fmt.Print("Enter password: ")

// Read password without echo (use os.Stdin.Fd() for better compatibility)
passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
fd, err := stdinFD()
if err != nil {
return fmt.Errorf("failed to get stdin fd: %w", err)
}
passwordBytes, err := term.ReadPassword(fd)
if err != nil {
// Fallback for non-terminal input
reader := bufio.NewReader(os.Stdin)
Expand Down
47 changes: 40 additions & 7 deletions internal/adapters/in/cli/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"path/filepath"
"regexp"
"strings"
"time"

tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -463,8 +464,8 @@ func buildAndPush(ctx context.Context, version, platform, dockerfile string, bui
// Cloudflare's 100MB per-request limit. Loading locally then
// using docker push gives us chunked uploads (~5MB per request).
fmt.Println("\nBuilding image...")
buildCmd := exec.CommandContext(ctx, "docker", buildImageArgs(version, platform, dockerfile, buildArgs, versionRef, latestRef)...) // #nosec G204
buildCmd.Env = append(os.Environ(), "VERSION="+version)
buildCmd := exec.CommandContext(ctx, "docker", buildImageArgs(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef)...) // #nosec G204
buildCmd.Env = os.Environ() // VERSION is now passed as --build-arg VERSION=<value>
buildCmd.Stdout = os.Stdout
buildCmd.Stderr = os.Stderr
if err := buildCmd.Run(); err != nil {
Expand All @@ -484,23 +485,52 @@ func buildAndPush(ctx context.Context, version, platform, dockerfile string, bui
return nil
}

// standardBuildArgs returns the standard set of git-related build args as
// explicit KEY=VALUE pairs. User-supplied args are appended after and take
// precedence (Docker uses the last occurrence of a duplicate key).
func standardBuildArgs(ctx context.Context, version string) []string {
gitSHA := resolveGitSHA(ctx)
buildTime := time.Now().UTC().Format(time.RFC3339)
return []string{
"VERSION=" + version,
"GIT_TAG=" + version,
"GIT_SHA=" + gitSHA,
"BUILD_TIME=" + buildTime,
}
}

// resolveGitSHA returns the short git SHA of HEAD, or "unknown" if unavailable.
func resolveGitSHA(ctx context.Context) string {
out, err := exec.CommandContext(ctx, "git", "rev-parse", "--short", "HEAD").Output() // #nosec G204
if err != nil {
return "unknown"
}
return strings.TrimSpace(string(out))
}

// buildImageArgs constructs the docker buildx build arguments.
// Uses --load instead of --push so the image is loaded into the local
// daemon, allowing docker push to handle the upload with chunked requests.
func buildImageArgs(version, platform, dockerfile string, buildArgs []string, versionRef, latestRef string) []string {
func buildImageArgs(ctx context.Context, version, platform, dockerfile string, buildArgs []string, versionRef, latestRef string) []string {
args := []string{
"buildx", "build",
"--platform", platform,
"-f", dockerfile,
"-t", latestRef,
"--build-arg", "VERSION",
}
if version != "latest" {
args = append(args, "-t", versionRef)
}

// Inject standard git build args as explicit KEY=VALUE pairs.
// User-supplied --build-arg flags are appended AFTER so they override defaults.
for _, ba := range standardBuildArgs(ctx, version) {
args = append(args, "--build-arg", ba)
}
for _, ba := range buildArgs {
args = append(args, "--build-arg", ba)
}

args = append(args, "--load", ".")
return args
}
Expand Down Expand Up @@ -682,23 +712,26 @@ func parseImageRef(image string) (registry, name, tag string) {
}

// getGitVersion returns git describe output, or empty string if unavailable.
// When it falls back it prints a warning to stderr so the user knows the
// image will be tagged "latest" rather than a real version.
func getGitVersion(ctx context.Context) string {
out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--dirty").Output()
out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--dirty").Output() // #nosec G204
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: unable to determine git tag (%v) — image version will be 'latest'. Tag your repo to get versioned images.\n", err)
return ""
}
return strings.TrimSpace(string(out))
}

func dockerTag(ctx context.Context, src, dst string) error {
cmd := exec.CommandContext(ctx, "docker", "tag", src, dst)
cmd := exec.CommandContext(ctx, "docker", "tag", src, dst) //nolint:gosec // binary is constant; image refs validated by OCI ref parser
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

func dockerPush(ctx context.Context, ref string) error {
cmd := exec.CommandContext(ctx, "docker", "push", ref)
cmd := exec.CommandContext(ctx, "docker", "push", ref) //nolint:gosec // binary is constant; image ref validated by OCI ref parser
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
Expand Down
102 changes: 98 additions & 4 deletions internal/adapters/in/cli/push_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package cli

import (
"context"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -65,15 +69,56 @@ func TestParseImageRef(t *testing.T) {

func TestBuildAndPush_BuildArgs(t *testing.T) {
// Verify buildImageArgs produces --load instead of --push
args := buildImageArgs("v1.0.0", "linux/amd64", "Dockerfile", []string{"CGO_ENABLED=0"}, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest")
args := buildImageArgs(context.Background(), "v1.0.0", "linux/amd64", "Dockerfile", []string{"CGO_ENABLED=0"}, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest")

assert.Contains(t, args, "--load")
assert.NotContains(t, args, "--push")
assert.Contains(t, args, "--platform")
assert.Contains(t, args, "-f")
assert.Contains(t, args, "Dockerfile")
assert.Contains(t, args, "VERSION")
assert.NotContains(t, args, "VERSION=v1.0.0")
assert.Contains(t, args, "VERSION=v1.0.0")
}

func TestBuildImageArgsInjectsGitBuildArgs(t *testing.T) {
args := buildImageArgs(context.Background(), "v1.2.3", "linux/amd64", "Dockerfile", nil, "registry/img:v1.2.3", "registry/img:latest")

// Must contain explicit KEY=VALUE for all standard git build args
argStr := strings.Join(args, " ")
for _, key := range []string{"VERSION=v1.2.3", "GIT_TAG=v1.2.3", "GIT_SHA=", "BUILD_TIME="} {
if !strings.Contains(argStr, key) {
t.Errorf("expected args to contain %q, got: %s", key, argStr)
}
}

// Must NOT contain bare "--build-arg VERSION" (without =value)
for i, a := range args {
if a == "--build-arg" && i+1 < len(args) && args[i+1] == "VERSION" {
t.Error("found bare '--build-arg VERSION' (without =value); should be '--build-arg VERSION=v1.2.3'")
}
}
}

func TestBuildImageArgsUserArgsOverrideDefaults(t *testing.T) {
userArgs := []string{"GIT_TAG=custom-override"}
args := buildImageArgs(context.Background(), "v1.2.3", "linux/amd64", "Dockerfile", userArgs, "r/i:v1.2.3", "r/i:latest")

// Count how many times GIT_TAG appears and track the last occurrence index.
// Docker uses the last occurrence of a duplicate --build-arg key, so the user
// override must come after the default injected value.
count := 0
lastIdx := -1
for i, a := range args {
if a == "--build-arg" && i+1 < len(args) && strings.HasPrefix(args[i+1], "GIT_TAG=") {
count++
lastIdx = i + 1
}
}
if count < 2 {
t.Errorf("expected GIT_TAG to appear twice (default + override), got %d", count)
}
if lastIdx < 0 || args[lastIdx] != "GIT_TAG="+userArgs[0][len("GIT_TAG="):] {
t.Errorf("expected last GIT_TAG= arg to be the user override %q, got %q", "GIT_TAG=custom-override", args[lastIdx])
}
}

func TestParseTagRef(t *testing.T) {
Expand Down Expand Up @@ -142,7 +187,7 @@ func TestVersionFromTagRefs(t *testing.T) {
}

func TestBuildImageArgs_CustomDockerfile(t *testing.T) {
args := buildImageArgs("v1.0.0", "linux/amd64", "docker/app/Dockerfile", nil, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest")
args := buildImageArgs(context.Background(), "v1.0.0", "linux/amd64", "docker/app/Dockerfile", nil, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest")

assert.Contains(t, args, "-f")
assert.Contains(t, args, "docker/app/Dockerfile")
Expand Down Expand Up @@ -301,6 +346,55 @@ func TestSplitLabelPairs(t *testing.T) {
}
}

func TestGetGitVersionNoTagsReturnsFallbackAndWarns(t *testing.T) {
ctx := context.Background()

// Create a temp dir with a git repo that has no tags
tmpDir := t.TempDir()
if err := exec.Command("git", "-C", tmpDir, "init").Run(); err != nil { // #nosec G204
t.Skipf("git init failed: %v", err)
}
// Need at least one commit so git describe has something to describe
if err := exec.Command("git", "-C", tmpDir, "-c", "user.email=test@test.com", "-c", "user.name=Test", "commit", "--allow-empty", "-m", "init").Run(); err != nil { // #nosec G204
t.Skipf("git commit failed: %v", err)
}

// Change to the tmpDir for this test so getGitVersion uses the tag-less repo
origDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(tmpDir); err != nil { // #nosec G204
t.Fatal(err)
}
defer os.Chdir(origDir) // #nosec G204

// Redirect stderr to capture the warning
origStderr := os.Stderr
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stderr = w
defer func() {
w.Close()
os.Stderr = origStderr
}()

v := getGitVersion(ctx)

w.Close()
os.Stderr = origStderr
stderrOutput, _ := io.ReadAll(r)

// Should return "" (fallback to "latest" handled by determineVersion)
assert.Equal(t, "", v, "expected empty string fallback when no git tags exist")

// Should have printed a warning to stderr
assert.Contains(t, string(stderrOutput), "latest",
"expected a warning about 'latest' fallback on stderr")
}
Comment on lines +373 to +396
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Double w.Close() — second close in defer is redundant and returns an error.

w.Close() is called explicitly on line 386, then the deferred closure on line 380 closes it again. Similarly, os.Stderr is restored on line 387 and again in the defer on line 381. The double close won't crash but is a code smell. Restructure so the defer only acts as a safety net.

♻️ Suggested cleanup
-	origStderr := os.Stderr
 	r, w, err := os.Pipe()
 	if err != nil {
 		t.Fatal(err)
 	}
+	origStderr := os.Stderr
 	os.Stderr = w
-	defer func() {
-		w.Close()
-		os.Stderr = origStderr
-	}()
 
 	v := getGitVersion(ctx)
 
 	w.Close()
 	os.Stderr = origStderr
 	stderrOutput, _ := io.ReadAll(r)
+	r.Close()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
origStderr := os.Stderr
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stderr = w
defer func() {
w.Close()
os.Stderr = origStderr
}()
v := getGitVersion(ctx)
w.Close()
os.Stderr = origStderr
stderrOutput, _ := io.ReadAll(r)
// Should return "" (fallback to "latest" handled by determineVersion)
assert.Equal(t, "", v, "expected empty string fallback when no git tags exist")
// Should have printed a warning to stderr
assert.Contains(t, string(stderrOutput), "latest",
"expected a warning about 'latest' fallback on stderr")
}
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
origStderr := os.Stderr
os.Stderr = w
v := getGitVersion(ctx)
w.Close()
os.Stderr = origStderr
stderrOutput, _ := io.ReadAll(r)
r.Close()
// Should return "" (fallback to "latest" handled by determineVersion)
assert.Equal(t, "", v, "expected empty string fallback when no git tags exist")
// Should have printed a warning to stderr
assert.Contains(t, string(stderrOutput), "latest",
"expected a warning about 'latest' fallback on stderr")
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/adapters/in/cli/push_test.go` around lines 373 - 396, The test
currently closes w and restores os.Stderr twice; change the defer to only
restore os.Stderr (remove w.Close() from the deferred func) and remove the later
explicit os.Stderr = origStderr so there’s a single restore, but keep the
explicit w.Close() before reading from r (so w is closed prior to io.ReadAll);
update the defer and the post-getGitVersion cleanup accordingly around the
variables w, r, origStderr and the call to getGitVersion.


func TestParseLabelPair(t *testing.T) {
tests := []struct {
name string
Expand Down
2 changes: 1 addition & 1 deletion internal/adapters/in/cli/remote/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
// PasswordRequest represents the request body for POST /auth/password.
type PasswordRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Password string `json:"password"` //nolint:gosec // intentional: CLI credential DTO for auth endpoint
}

// PasswordResponse represents the response from POST /auth/password.
Expand Down
Loading
Loading