From 5c156968f230c544f337f013b7389d977d1b1a59 Mon Sep 17 00:00:00 2001 From: Tim Thacker Date: Tue, 24 Feb 2026 15:53:01 +1100 Subject: [PATCH 1/8] feat: replace device code polling with localhost callback auth Rewrite login flow: CLI starts a localhost HTTP server, opens browser to Cognito, receives callback with session ID, fetches tokens from backend. No more code confirmation or polling delay. - Replace DeviceFlowLogin() with Login() using localhost callback - Use http.NewRequestWithContext for proper context propagation - Add tracer spans to all auth functions - Simplify get_token.go to delegate to auth.GetValidToken() Co-Authored-By: Claude Opus 4.6 --- cmd/cli/cmd/auth.go | 6 +- internal/auth/login.go | 236 +++++++++++++++++++++----------------- internal/lib/get_token.go | 105 +---------------- 3 files changed, 135 insertions(+), 212 deletions(-) diff --git a/cmd/cli/cmd/auth.go b/cmd/cli/cmd/auth.go index 7fac78b..52f27cb 100644 --- a/cmd/cli/cmd/auth.go +++ b/cmd/cli/cmd/auth.go @@ -16,13 +16,13 @@ import ( var authCmd = &cobra.Command{ Use: "auth", Short: "Manage authentication", - Long: "Authenticate with the Nullify API using device flow, manage credentials and tokens.", + Long: "Authenticate with the Nullify API, manage credentials and tokens.", } var loginCmd = &cobra.Command{ Use: "login", Short: "Log in to Nullify", - Long: "Authenticate with your Nullify instance using device flow authentication.", + Long: "Authenticate with your Nullify instance. Opens your browser to log in with your identity provider.", Run: func(cmd *cobra.Command, args []string) { ctx := setupLogger() defer logger.L(ctx).Sync() @@ -49,7 +49,7 @@ var loginCmd = &cobra.Command{ os.Exit(1) } - err = auth.DeviceFlowLogin(ctx, sanitizedHost) + err = auth.Login(ctx, sanitizedHost) if err != nil { fmt.Fprintf(os.Stderr, "Error: login failed: %v\n", err) os.Exit(1) diff --git a/internal/auth/login.go b/internal/auth/login.go index 28d73e1..46d5c60 100644 --- a/internal/auth/login.go +++ b/internal/auth/login.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "net" "net/http" "os/exec" "runtime" @@ -11,20 +12,17 @@ import ( "time" "github.com/nullify-platform/logger/pkg/logger" + "github.com/nullify-platform/logger/pkg/logger/tracer" ) var httpClient = &http.Client{Timeout: 30 * time.Second} -type deviceCodeResponse struct { - DeviceCode string `json:"device_code"` - UserCode string `json:"user_code"` - VerificationURI string `json:"verification_uri"` - VerificationURIComplete string `json:"verification_uri_complete"` - ExpiresIn int `json:"expires_in"` - Interval int `json:"interval"` +type cliSessionResponse struct { + SessionID string `json:"session_id"` + AuthURL string `json:"auth_url"` } -type deviceTokenResponse struct { +type cliTokenResponse struct { AccessToken string `json:"access_token,omitempty"` RefreshToken string `json:"refresh_token,omitempty"` ExpiresIn int `json:"expires_in,omitempty"` @@ -32,96 +30,109 @@ type deviceTokenResponse struct { QueryParameters map[string]string `json:"query_parameters,omitempty"` } -type cliConfigResponse struct { - DeviceAuthEnabled bool `json:"device_auth_enabled"` - VerificationURI string `json:"verification_uri"` - AppDomain string `json:"app_domain"` -} +const successHTML = ` +Nullify CLI + +
+

Authenticated Successfully!

+

You can close this tab and return to your terminal.

+
` + +func Login(ctx context.Context, host string) error { + ctx, span := tracer.FromContext(ctx).Start(ctx, "auth.Login") + defer span.End() -func DeviceFlowLogin(ctx context.Context, host string) error { - // 1. Check CLI config endpoint - cliConfig, err := getCLIConfig(ctx, host) + // 1. Start localhost server on random port + listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { - logger.L(ctx).Debug("cli config endpoint not available, proceeding with defaults", logger.Err(err)) - cliConfig = &cliConfigResponse{ - DeviceAuthEnabled: true, - } + return fmt.Errorf("failed to start local server: %w", err) } + port := listener.Addr().(*net.TCPAddr).Port + + // 2. Set up callback handler + sessionCh := make(chan string, 1) + errCh := make(chan error, 1) + + mux := http.NewServeMux() + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + sessionID := r.URL.Query().Get("session_id") + if sessionID == "" { + http.Error(w, "missing session_id", http.StatusBadRequest) + return + } - if !cliConfig.DeviceAuthEnabled { - return fmt.Errorf("device flow authentication is not enabled on this instance") - } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, successHTML) + + sessionCh <- sessionID + }) - // 2. Request device code - codeResp, err := requestDeviceCode(ctx, host) + server := &http.Server{Handler: mux} + go func() { + if serveErr := server.Serve(listener); serveErr != nil && serveErr != http.ErrServerClosed { + errCh <- serveErr + } + }() + defer server.Close() + + // 3. Create session on backend + sessionResp, err := createCLISession(ctx, host, port) if err != nil { - return fmt.Errorf("failed to request device code: %w", err) + return fmt.Errorf("failed to create auth session: %w", err) } - // 3. Print instructions and open browser - fmt.Printf("\nTo authenticate, visit:\n %s\n\n", codeResp.VerificationURIComplete) - fmt.Printf("And confirm the code: %s\n\n", codeResp.UserCode) - fmt.Println("Waiting for authentication...") + // 4. Open browser + fmt.Printf("\nOpening browser to authenticate...\n") + fmt.Printf("If the browser doesn't open, visit:\n %s\n\n", sessionResp.AuthURL) - if err := openBrowser(codeResp.VerificationURIComplete); err != nil { + if err := openBrowser(sessionResp.AuthURL); err != nil { logger.L(ctx).Debug("could not open browser automatically", logger.Err(err)) fmt.Println("(Could not open browser automatically. Please open the URL above manually.)") } - // 4. Poll for token - interval := time.Duration(codeResp.Interval) * time.Second - if interval == 0 { - interval = 5 * time.Second - } - - deadline := time.Now().Add(time.Duration(codeResp.ExpiresIn) * time.Second) - - for time.Now().Before(deadline) { - time.Sleep(interval) - - tokenResp, err := pollDeviceToken(ctx, host, codeResp.DeviceCode) - if err != nil { - logger.L(ctx).Debug("poll error", logger.Err(err)) - continue - } + fmt.Println("Waiting for authentication...") - if tokenResp.Error == "authorization_pending" { - continue - } + // 5. Wait for callback (10 minute timeout) + var sessionID string + select { + case sessionID = <-sessionCh: + case err := <-errCh: + return fmt.Errorf("local server error: %w", err) + case <-time.After(10 * time.Minute): + return fmt.Errorf("authentication timed out") + } - if tokenResp.Error == "slow_down" { - interval += 5 * time.Second - continue - } + // 6. Fetch tokens from backend + tokenResp, err := fetchCLIToken(ctx, host, sessionID) + if err != nil { + return fmt.Errorf("failed to fetch tokens: %w", err) + } - if tokenResp.Error != "" { - return fmt.Errorf("authentication failed: %s", tokenResp.Error) - } + if tokenResp.Error != "" { + return fmt.Errorf("authentication failed: %s", tokenResp.Error) + } - // Success - store tokens - expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Unix() - - err = SaveHostCredentials(host, HostCredentials{ - AccessToken: tokenResp.AccessToken, - RefreshToken: tokenResp.RefreshToken, - ExpiresAt: expiresAt, - QueryParameters: tokenResp.QueryParameters, - }) - if err != nil { - return fmt.Errorf("failed to save credentials: %w", err) - } + // 7. Store credentials + expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Unix() - // Save config with host - err = SaveConfig(&Config{Host: host}) - if err != nil { - return fmt.Errorf("failed to save config: %w", err) - } + err = SaveHostCredentials(host, HostCredentials{ + AccessToken: tokenResp.AccessToken, + RefreshToken: tokenResp.RefreshToken, + ExpiresAt: expiresAt, + QueryParameters: tokenResp.QueryParameters, + }) + if err != nil { + return fmt.Errorf("failed to save credentials: %w", err) + } - fmt.Println("\nAuthenticated successfully!") - return nil + err = SaveConfig(&Config{Host: host}) + if err != nil { + return fmt.Errorf("failed to save config: %w", err) } - return fmt.Errorf("authentication timed out - the code has expired") + fmt.Println("\nAuthenticated successfully!") + return nil } func Logout(host string) error { @@ -129,6 +140,9 @@ func Logout(host string) error { } func GetValidToken(ctx context.Context, host string) (string, error) { + ctx, span := tracer.FromContext(ctx).Start(ctx, "auth.GetValidToken") + defer span.End() + creds, err := LoadCredentials() if err != nil { return "", fmt.Errorf("not authenticated - run 'nullify auth login'") @@ -155,68 +169,70 @@ func GetValidToken(ctx context.Context, host string) (string, error) { return hostCreds.AccessToken, nil } -func getCLIConfig(ctx context.Context, host string) (*cliConfigResponse, error) { - url := fmt.Sprintf("https://%s/auth/cli_config", host) +func createCLISession(ctx context.Context, host string, port int) (*cliSessionResponse, error) { + ctx, span := tracer.FromContext(ctx).Start(ctx, "auth.createCLISession") + defer span.End() + + url := fmt.Sprintf("https://%s/auth/cli/session", host) - resp, err := httpClient.Get(url) + bodyData, err := json.Marshal(map[string]int{"port": port}) if err != nil { return nil, err } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("cli_config returned status %d", resp.StatusCode) - } - var config cliConfigResponse - err = json.NewDecoder(resp.Body).Decode(&config) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(bodyData))) if err != nil { return nil, err } + req.Header.Set("Content-Type", "application/json") - return &config, nil -} - -func requestDeviceCode(ctx context.Context, host string) (*deviceCodeResponse, error) { - url := fmt.Sprintf("https://%s/auth/device/code", host) - - resp, err := httpClient.Post(url, "application/json", strings.NewReader("{}")) + resp, err := httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("device code request returned status %d", resp.StatusCode) + return nil, fmt.Errorf("session request returned status %d", resp.StatusCode) } - var codeResp deviceCodeResponse - err = json.NewDecoder(resp.Body).Decode(&codeResp) + var sessionResp cliSessionResponse + err = json.NewDecoder(resp.Body).Decode(&sessionResp) if err != nil { return nil, err } - return &codeResp, nil + return &sessionResp, nil } -func pollDeviceToken(ctx context.Context, host string, deviceCode string) (*deviceTokenResponse, error) { - url := fmt.Sprintf("https://%s/auth/device/token", host) +func fetchCLIToken(ctx context.Context, host string, sessionID string) (*cliTokenResponse, error) { + ctx, span := tracer.FromContext(ctx).Start(ctx, "auth.fetchCLIToken") + defer span.End() + + url := fmt.Sprintf("https://%s/auth/cli/token", host) - bodyData, err := json.Marshal(map[string]string{"device_code": deviceCode}) + bodyData, err := json.Marshal(map[string]string{"session_id": sessionID}) if err != nil { return nil, err } - resp, err := httpClient.Post(url, "application/json", strings.NewReader(string(bodyData))) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(bodyData))) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("poll returned status %d", resp.StatusCode) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token request returned status %d", resp.StatusCode) } - var tokenResp deviceTokenResponse + var tokenResp cliTokenResponse err = json.NewDecoder(resp.Body).Decode(&tokenResp) if err != nil { return nil, err @@ -226,9 +242,17 @@ func pollDeviceToken(ctx context.Context, host string, deviceCode string) (*devi } func refreshToken(ctx context.Context, host string, refreshTok string) (string, error) { + ctx, span := tracer.FromContext(ctx).Start(ctx, "auth.refreshToken") + defer span.End() + url := fmt.Sprintf("https://%s/auth/refresh_token?refresh_token=%s", host, refreshTok) - resp, err := httpClient.Get(url) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + + resp, err := httpClient.Do(req) if err != nil { return "", err } diff --git a/internal/lib/get_token.go b/internal/lib/get_token.go index 13641dd..49bd433 100644 --- a/internal/lib/get_token.go +++ b/internal/lib/get_token.go @@ -8,8 +8,8 @@ import ( "net/http" "os" "strings" - "time" + "github.com/nullify-platform/cli/internal/auth" "github.com/nullify-platform/cli/internal/client" "github.com/nullify-platform/logger/pkg/logger" ) @@ -86,7 +86,7 @@ func GetNullifyToken( } // 4. Stored credentials from ~/.nullify/credentials.json - storedToken, err := getStoredToken(ctx, nullifyHost) + storedToken, err := auth.GetValidToken(ctx, nullifyHost) if err == nil && storedToken != "" { logger.L(ctx).Debug("using token from stored credentials") return storedToken, nil @@ -94,104 +94,3 @@ func GetNullifyToken( return "", ErrNoToken } - -type storedCredentials struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresAt int64 `json:"expires_at"` - QueryParameters map[string]string `json:"query_parameters"` -} - -func getStoredToken(ctx context.Context, nullifyHost string) (string, error) { - homeDir, err := os.UserHomeDir() - if err != nil { - return "", err - } - - credPath := homeDir + "/.nullify/credentials.json" - data, err := os.ReadFile(credPath) - if err != nil { - return "", err - } - - var creds map[string]storedCredentials - - err = json.Unmarshal(data, &creds) - if err != nil { - return "", err - } - - hostCreds, ok := creds[nullifyHost] - if !ok { - return "", fmt.Errorf("no credentials for host %s", nullifyHost) - } - - if hostCreds.AccessToken == "" { - return "", fmt.Errorf("no access token for host %s", nullifyHost) - } - - // Check if token is expired - if hostCreds.ExpiresAt > 0 && time.Now().Unix() > hostCreds.ExpiresAt { - // Attempt refresh if we have a refresh token - if hostCreds.RefreshToken != "" { - logger.L(ctx).Debug("stored token expired, attempting refresh") - return refreshStoredToken(ctx, nullifyHost, hostCreds.RefreshToken, credPath) - } - return "", fmt.Errorf("token expired for host %s - run 'nullify auth login'", nullifyHost) - } - - return hostCreds.AccessToken, nil -} - -func refreshStoredToken(ctx context.Context, host, refreshTok, credPath string) (string, error) { - refreshURL := fmt.Sprintf("https://%s/auth/refresh_token?refresh_token=%s", host, refreshTok) - - httpClient := &http.Client{Timeout: 30 * time.Second} - resp, err := httpClient.Get(refreshURL) - if err != nil { - return "", fmt.Errorf("refresh request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("token refresh failed with status %d", resp.StatusCode) - } - - var result struct { - AccessToken string `json:"accessToken"` - ExpiresIn int `json:"expiresIn"` - QueryParameters map[string]string `json:"queryParameters"` - } - - err = json.NewDecoder(resp.Body).Decode(&result) - if err != nil { - return "", fmt.Errorf("failed to decode refresh response: %w", err) - } - - // Update stored credentials - data, err := os.ReadFile(credPath) - if err != nil { - return result.AccessToken, nil // return token even if we can't save - } - - var creds map[string]storedCredentials - if err := json.Unmarshal(data, &creds); err != nil { - return result.AccessToken, nil - } - - creds[host] = storedCredentials{ - AccessToken: result.AccessToken, - RefreshToken: refreshTok, - ExpiresAt: time.Now().Add(time.Duration(result.ExpiresIn) * time.Second).Unix(), - QueryParameters: result.QueryParameters, - } - - updatedData, err := json.MarshalIndent(creds, "", " ") - if err != nil { - return result.AccessToken, nil - } - - _ = os.WriteFile(credPath, updatedData, 0600) - - return result.AccessToken, nil -} From 0ef994eb72c32a1011384bc8ec098c58088cd0cd Mon Sep 17 00:00:00 2001 From: Tim Thacker Date: Tue, 24 Feb 2026 15:59:53 +1100 Subject: [PATCH 2/8] fix: harden login with CSRF check, duplicate guard, and ctx cancellation - Verify received session_id matches expected one (CSRF protection) - Use sync.Once to guard against duplicate callback invocations - Add ctx.Done() case to select for proper Ctrl+C cancellation - Improve success HTML with checkmark SVG - Create session before starting server to have expected session ID Co-Authored-By: Claude Opus 4.6 --- internal/auth/login.go | 62 ++++++++++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 23 deletions(-) diff --git a/internal/auth/login.go b/internal/auth/login.go index 46d5c60..4051a40 100644 --- a/internal/auth/login.go +++ b/internal/auth/login.go @@ -9,6 +9,7 @@ import ( "os/exec" "runtime" "strings" + "sync" "time" "github.com/nullify-platform/logger/pkg/logger" @@ -31,11 +32,18 @@ type cliTokenResponse struct { } const successHTML = ` -Nullify CLI - -
-

Authenticated Successfully!

-

You can close this tab and return to your terminal.

+Nullify CLI + +
+ +

Authenticated Successfully!

+

You can close this tab and return to your terminal.

` func Login(ctx context.Context, host string) error { @@ -49,23 +57,35 @@ func Login(ctx context.Context, host string) error { } port := listener.Addr().(*net.TCPAddr).Port - // 2. Set up callback handler + // 2. Create session on backend first so we know the expected session ID + sessionResp, err := createCLISession(ctx, host, port) + if err != nil { + listener.Close() + return fmt.Errorf("failed to create auth session: %w", err) + } + + // 3. Set up callback handler that validates session ID sessionCh := make(chan string, 1) errCh := make(chan error, 1) + var callbackOnce sync.Once mux := http.NewServeMux() mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { - sessionID := r.URL.Query().Get("session_id") - if sessionID == "" { - http.Error(w, "missing session_id", http.StatusBadRequest) + receivedID := r.URL.Query().Get("session_id") + + // Verify the session ID matches what we requested (CSRF protection) + if receivedID != sessionResp.SessionID { + http.Error(w, "invalid session", http.StatusForbidden) return } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, successHTML) - - sessionCh <- sessionID + // Only process the first valid callback (guard against duplicates) + callbackOnce.Do(func() { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, successHTML) + sessionCh <- receivedID + }) }) server := &http.Server{Handler: mux} @@ -76,12 +96,6 @@ func Login(ctx context.Context, host string) error { }() defer server.Close() - // 3. Create session on backend - sessionResp, err := createCLISession(ctx, host, port) - if err != nil { - return fmt.Errorf("failed to create auth session: %w", err) - } - // 4. Open browser fmt.Printf("\nOpening browser to authenticate...\n") fmt.Printf("If the browser doesn't open, visit:\n %s\n\n", sessionResp.AuthURL) @@ -91,16 +105,18 @@ func Login(ctx context.Context, host string) error { fmt.Println("(Could not open browser automatically. Please open the URL above manually.)") } - fmt.Println("Waiting for authentication...") + fmt.Println("Waiting for authentication... (press Ctrl+C to cancel)") - // 5. Wait for callback (10 minute timeout) + // 5. Wait for callback with context cancellation support var sessionID string select { case sessionID = <-sessionCh: case err := <-errCh: return fmt.Errorf("local server error: %w", err) + case <-ctx.Done(): + return fmt.Errorf("authentication cancelled") case <-time.After(10 * time.Minute): - return fmt.Errorf("authentication timed out") + return fmt.Errorf("authentication timed out — the session has expired") } // 6. Fetch tokens from backend From 90d5a134aa3278f53f3f3a3348d3cbfa0b9e4fb7 Mon Sep 17 00:00:00 2001 From: Tim Thacker Date: Tue, 24 Feb 2026 16:09:49 +1100 Subject: [PATCH 3/8] Fix duplicate callback handling: serve success page on repeated requests Previously, a second callback to the CLI localhost server after sync.Once would return an empty response. Now it returns the success HTML page. Co-Authored-By: Claude Opus 4.6 --- internal/auth/login.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/auth/login.go b/internal/auth/login.go index 4051a40..9613d86 100644 --- a/internal/auth/login.go +++ b/internal/auth/login.go @@ -80,12 +80,19 @@ func Login(ctx context.Context, host string) error { } // Only process the first valid callback (guard against duplicates) + processed := false callbackOnce.Do(func() { + processed = true w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) fmt.Fprint(w, successHTML) sessionCh <- receivedID }) + if !processed { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, successHTML) + } }) server := &http.Server{Handler: mux} From 407b8876d51a16f947f53f4568f9f13764943c03 Mon Sep 17 00:00:00 2001 From: Tim Thacker Date: Tue, 24 Feb 2026 16:15:13 +1100 Subject: [PATCH 4/8] Add signal handling for graceful Ctrl+C during login Wrap login context with signal.NotifyContext so Ctrl+C triggers the ctx.Done() select case, printing "authentication cancelled" cleanly instead of an abrupt process termination. Co-Authored-By: Claude Opus 4.6 --- cmd/cli/cmd/auth.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cmd/cli/cmd/auth.go b/cmd/cli/cmd/auth.go index 52f27cb..4e98af8 100644 --- a/cmd/cli/cmd/auth.go +++ b/cmd/cli/cmd/auth.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "os" + "os/signal" + "syscall" "time" "github.com/nullify-platform/cli/internal/auth" @@ -27,6 +29,10 @@ var loginCmd = &cobra.Command{ ctx := setupLogger() defer logger.L(ctx).Sync() + // Wrap context with signal handling so Ctrl+C triggers graceful cancellation + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() + loginHost := host // If no host from flag, try config file From 57e251e3583d8e336a76127ae00f64d9ff1c0e0e Mon Sep 17 00:00:00 2001 From: Tim Thacker Date: Tue, 24 Feb 2026 16:51:33 +1100 Subject: [PATCH 5/8] Fix go.mod: run go mod tidy to sync dependencies Co-Authored-By: Claude Opus 4.6 --- go.mod | 13 +++++++++++-- go.sum | 35 ++++++++++++++++++++++++++++------- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 54a7388..1bb2513 100644 --- a/go.mod +++ b/go.mod @@ -5,15 +5,16 @@ go 1.24.5 toolchain go1.25.5 require ( - github.com/alexflint/go-arg v1.6.1 github.com/docker/docker v28.5.2+incompatible github.com/google/go-github/v80 v80.0.0 + github.com/mark3labs/mcp-go v0.44.0 github.com/nullify-platform/logger v1.27.12 + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.9 ) require ( github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/alexflint/go-scalar v1.2.0 // indirect github.com/aws/aws-lambda-go v1.50.0 // indirect github.com/aws/aws-sdk-go-v2 v1.40.0 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.2 // indirect @@ -32,6 +33,8 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 // indirect github.com/aws/smithy-go v1.23.2 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect @@ -46,6 +49,9 @@ require ( github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/term v0.5.0 // indirect @@ -54,6 +60,9 @@ require ( github.com/opencontainers/image-spec v1.1.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect diff --git a/go.sum b/go.sum index 6ce9f8c..55830b3 100644 --- a/go.sum +++ b/go.sum @@ -2,10 +2,6 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/alexflint/go-arg v1.6.1 h1:uZogJ6VDBjcuosydKgvYYRhh9sRCusjOvoOLZopBlnA= -github.com/alexflint/go-arg v1.6.1/go.mod h1:nQ0LFYftLJ6njcaee0sU+G0iS2+2XJQfA8I062D0LGc= -github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw= -github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= github.com/aws/aws-lambda-go v1.50.0 h1:0GzY18vT4EsCvIyk3kn3ZH5Jg30NRlgYaai1w0aGPMU= github.com/aws/aws-lambda-go v1.50.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc= @@ -42,6 +38,10 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 h1:a5UTtD4mHBU3t0o6aHQZFJTNKVfx github.com/aws/aws-sdk-go-v2/service/sts v1.41.2/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso= github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -50,7 +50,7 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= @@ -63,6 +63,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -81,10 +83,19 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mark3labs/mcp-go v0.44.0 h1:OlYfcVviAnwNN40QZUrrzU0QZjq3En7rCU5X09a/B7I= +github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= @@ -103,16 +114,25 @@ github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQ github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= 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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA= @@ -141,6 +161,7 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= From a05bdf6e959ded8fbf6fcf7ef5a16568b3397d68 Mon Sep 17 00:00:00 2001 From: Tim Thacker Date: Tue, 24 Feb 2026 16:55:34 +1100 Subject: [PATCH 6/8] Migrate CLI from go-arg to cobra and clean up struct tags - Replace go-arg-based main.go with cobra command structure - Remove go-arg struct tags from DAST and auth models - Add HTTP client timeout to NullifyClient - Add generate-api Makefile target Co-Authored-By: Claude Opus 4.6 --- Makefile | 3 ++ cmd/cli/main.go | 75 ++------------------------------------- internal/client/client.go | 2 ++ internal/dast/dast.go | 22 ++++++------ internal/models/auth.go | 5 --- 5 files changed, 18 insertions(+), 89 deletions(-) diff --git a/Makefile b/Makefile index 09c14a0..f6e6a58 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,9 @@ lint-docker: docker build --quiet --target hadolint -t hadolint:latest . docker run --rm -v $(shell pwd):/app -w /app hadolint hadolint Dockerfile demo_server/Dockerfile +generate-api: + go run ./scripts/generate/main.go --spec ../public-docs/specs/merged-openapi.yml --output internal/api --cmd-output internal/commands + unit: go test -v -skip TestIntegration ./... diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 3920f4c..b9876ca 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -1,84 +1,13 @@ package main import ( - "context" "os" - "github.com/nullify-platform/cli/internal/client" - "github.com/nullify-platform/cli/internal/dast" - "github.com/nullify-platform/cli/internal/lib" - "github.com/nullify-platform/cli/internal/models" - "github.com/nullify-platform/logger/pkg/logger" - - "github.com/alexflint/go-arg" + "github.com/nullify-platform/cli/cmd/cli/cmd" ) -type args struct { - DAST *dast.DAST `arg:"subcommand:dast" help:"Test the given app for bugs and vulnerabilities"` - Host string `arg:"--host" default:"api.nullify.ai" help:"The base URL of your Nullify API instance"` - Verbose bool `arg:"-v" help:"Enable verbose logging"` - Debug bool `arg:"-d" help:"Enable debug logging"` - AuthConfig string `arg:"--auth-config" help:"The path to the auth config file"` - models.AuthSources -} - -func (args) Version() string { - return logger.Version -} - func main() { - ctx := context.TODO() - - var args args - p := arg.MustParse(&args) - - logLevel := "warn" - if args.Verbose { - logLevel = "info" - } - if args.Debug { - logLevel = "debug" - } - ctx, err := logger.ConfigureDevelopmentLogger(ctx, logLevel) - if err != nil { - panic(err) - } - defer logger.L(ctx).Sync() - - switch { - case args.DAST != nil && args.DAST.Path != "": - nullifyClient := getNullifyClient(ctx, &args) - err = dast.RunDASTScan(ctx, args.DAST, nullifyClient, logLevel) - if err != nil { - logger.L(ctx).Error( - "failed to run dast scan", - logger.Err(err), - ) - os.Exit(1) - } - default: - p.WriteHelp(os.Stdout) - } -} - -func getNullifyClient(ctx context.Context, args *args) *client.NullifyClient { - nullifyHost, err := lib.SanitizeNullifyHost(args.Host) - if err != nil { - logger.L(ctx).Error( - "invalid host, must be in the format api..nullify.ai", - logger.String("host", args.Host), - ) + if err := cmd.Execute(); err != nil { os.Exit(1) } - - nullifyToken, err := lib.GetNullifyToken(ctx, nullifyHost, &args.AuthSources) - if err != nil { - logger.L(ctx).Error( - "failed to get token", - logger.Err(err), - ) - os.Exit(1) - } - - return client.NewNullifyClient(nullifyHost, nullifyToken) } diff --git a/internal/client/client.go b/internal/client/client.go index b20d342..fe63cb3 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -2,6 +2,7 @@ package client import ( "net/http" + "time" ) type NullifyClient struct { @@ -13,6 +14,7 @@ type NullifyClient struct { func NewNullifyClient(nullifyHost string, token string) *NullifyClient { httpClient := &http.Client{ + Timeout: 30 * time.Second, Transport: &authTransport{ nullifyHost: nullifyHost, token: token, diff --git a/internal/dast/dast.go b/internal/dast/dast.go index 5c381da..d1de3dd 100644 --- a/internal/dast/dast.go +++ b/internal/dast/dast.go @@ -10,21 +10,21 @@ import ( ) type DAST struct { - AppName string `arg:"--app-name" help:"The unique name of the app to be scanned, you can set this to anything e.g. Core API"` - Path string `arg:"--spec-path" help:"The file path to the OpenAPI file (both yaml and json are supported) e.g. ./openapi.yaml"` - TargetHost string `arg:"--target-host" help:"The base URL of the API to be scanned e.g. https://api.nullify.ai"` - AuthHeaders []string `arg:"--header" help:"List of headers for the DAST agent to authenticate with your API"` + AppName string + Path string + TargetHost string + AuthHeaders []string - GitHubOwner string `arg:"--github-owner" help:"The GitHub username or organisation"` - GitHubRepository string `arg:"--github-repo" help:"The repository name to create the Nullify issue dashboard in e.g. cli"` + GitHubOwner string + GitHubRepository string // local scan settings - Local bool `arg:"--local" help:"Test the given app locally for bugs and vulnerabilities in private networks"` - ImageLabel string `arg:"--image-label" default:"latest" help:"Version of the DAST local image that is used for scanning"` - ForcePullImage bool `arg:"--force-pull" help:"Force a docker pull of the latest version of the DAST local image"` - UseHostNetwork bool `arg:"--use-host-network" help:"Use the host network for the DAST local scan"` + Local bool + ImageLabel string + ForcePullImage bool + UseHostNetwork bool - AuthConfig string `arg:"--auth-config" help:"The path to the auth config file"` + AuthConfig string } func RunDASTScan(ctx context.Context, dast *DAST, nullifyClient *client.NullifyClient, logLevel string) error { diff --git a/internal/models/auth.go b/internal/models/auth.go index 1f17f5f..efb50cd 100644 --- a/internal/models/auth.go +++ b/internal/models/auth.go @@ -1,10 +1,5 @@ package models -type AuthSources struct { - NullifyToken string `json:"nullifyToken" arg:"--nullify-token" help:"Nullify API token"` - GitHubToken string `json:"githubToken" arg:"--github-token" help:"GitHub actions job token to exchange for a Nullify API token"` -} - type AuthMethod string const ( From dced36ccef038e052429290d99a9719cd9d71964 Mon Sep 17 00:00:00 2001 From: Tim Thacker Date: Tue, 24 Feb 2026 16:57:16 +1100 Subject: [PATCH 7/8] Fix Makefile build target for cobra package structure Change ./cmd/cli/... to ./cmd/cli since the cobra cmd subpackage is not a separate binary target. Co-Authored-By: Claude Opus 4.6 --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index f6e6a58..450e2a8 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ GOFLAGS := -ldflags "-X 'github.com/nullify-platform/logger/pkg/logger.Version=$ all: build build: - $(GOENV) go build $(GOFLAGS) -o bin/cli ./cmd/cli/... + $(GOENV) go build $(GOFLAGS) -o bin/cli ./cmd/cli clean: rm -rf ./bin ./vendor Gopkg.lock coverage.* From ba654d8781f713a645afed8600f1cb9526ea2c6a Mon Sep 17 00:00:00 2001 From: Tim Thacker Date: Tue, 24 Feb 2026 16:59:39 +1100 Subject: [PATCH 8/8] Fix lint errors: errcheck and gofmt issues Co-Authored-By: Claude Opus 4.6 --- cmd/cli/cmd/auth.go | 2 +- cmd/cli/cmd/completion.go | 8 ++++---- cmd/cli/cmd/dast.go | 22 +++++++++++----------- internal/api/client_test.go | 6 +++--- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/cmd/cli/cmd/auth.go b/cmd/cli/cmd/auth.go index 4e98af8..0219d63 100644 --- a/cmd/cli/cmd/auth.go +++ b/cmd/cli/cmd/auth.go @@ -46,7 +46,7 @@ var loginCmd = &cobra.Command{ // If still no host, prompt if loginHost == "" { fmt.Print("Enter your Nullify instance (e.g., api.acme.nullify.ai): ") - fmt.Scanln(&loginHost) + _, _ = fmt.Scanln(&loginHost) } sanitizedHost, err := lib.SanitizeNullifyHost(loginHost) diff --git a/cmd/cli/cmd/completion.go b/cmd/cli/cmd/completion.go index f6babb5..2423259 100644 --- a/cmd/cli/cmd/completion.go +++ b/cmd/cli/cmd/completion.go @@ -40,13 +40,13 @@ PowerShell: Run: func(cmd *cobra.Command, args []string) { switch args[0] { case "bash": - rootCmd.GenBashCompletion(os.Stdout) + _ = rootCmd.GenBashCompletion(os.Stdout) case "zsh": - rootCmd.GenZshCompletion(os.Stdout) + _ = rootCmd.GenZshCompletion(os.Stdout) case "fish": - rootCmd.GenFishCompletion(os.Stdout, true) + _ = rootCmd.GenFishCompletion(os.Stdout, true) case "powershell": - rootCmd.GenPowerShellCompletionWithDesc(os.Stdout) + _ = rootCmd.GenPowerShellCompletionWithDesc(os.Stdout) } }, } diff --git a/cmd/cli/cmd/dast.go b/cmd/cli/cmd/dast.go index 8ac6ae3..79ffb7c 100644 --- a/cmd/cli/cmd/dast.go +++ b/cmd/cli/cmd/dast.go @@ -19,7 +19,7 @@ var dastCmd = &cobra.Command{ dastArgs := getDastArgs(cmd) if dastArgs.Path == "" { - cmd.Help() + _ = cmd.Help() return } @@ -69,16 +69,16 @@ func getDastArgs(cmd *cobra.Command) *dast.DAST { dastAuthConfig, _ := cmd.Flags().GetString("dast-auth-config") return &dast.DAST{ - AppName: appName, - Path: specPath, - TargetHost: targetHost, - AuthHeaders: headers, - GitHubOwner: githubOwner, + AppName: appName, + Path: specPath, + TargetHost: targetHost, + AuthHeaders: headers, + GitHubOwner: githubOwner, GitHubRepository: githubRepo, - Local: local, - ImageLabel: imageLabel, - ForcePullImage: forcePull, - UseHostNetwork: useHostNetwork, - AuthConfig: dastAuthConfig, + Local: local, + ImageLabel: imageLabel, + ForcePullImage: forcePull, + UseHostNetwork: useHostNetwork, + AuthConfig: dastAuthConfig, } } diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 07ff528..8fff935 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -13,7 +13,7 @@ func TestClientDo_Success(t *testing.T) { t.Errorf("expected Authorization header, got %q", r.Header.Get("Authorization")) } w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"ok"}`)) + _, _ = w.Write([]byte(`{"status":"ok"}`)) })) defer server.Close() @@ -35,7 +35,7 @@ func TestClientDo_Success(t *testing.T) { func TestClientDo_4xxError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) - w.Write([]byte(`{"error":"not found"}`)) + _, _ = w.Write([]byte(`{"error":"not found"}`)) })) defer server.Close() @@ -54,7 +54,7 @@ func TestClientDo_4xxError(t *testing.T) { func TestClientDo_5xxError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(`{"error":"internal error"}`)) + _, _ = w.Write([]byte(`{"error":"internal error"}`)) })) defer server.Close()