Skip to content
Merged
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
31 changes: 11 additions & 20 deletions cmd/cli/cmd/chat.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
package cmd

import (
"errors"
"fmt"
"os"
"strings"

"github.com/nullify-platform/cli/internal/auth"
"github.com/nullify-platform/cli/internal/chat"
"github.com/nullify-platform/cli/internal/lib"
"github.com/nullify-platform/logger/pkg/logger"
"github.com/spf13/cobra"
)
Expand All @@ -27,31 +28,21 @@ Examples:
ctx := setupLogger(cmd.Context())
defer logger.L(ctx).Sync()

chatHost := resolveHost(ctx)

token, err := auth.GetValidToken(ctx, chatHost)
authCtx, err := resolveCommandAuth(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: not authenticated. Run 'nullify auth login' first.\n")
if errors.Is(err, lib.ErrNoToken) {
fmt.Fprintf(os.Stderr, "Error: not authenticated. Run 'nullify auth login' first.\n")
} else {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
}
os.Exit(ExitAuthError)
}

creds, err := auth.LoadCredentials()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to load credentials: %v\n", err)
os.Exit(1)
}

hostCreds := creds[auth.CredentialKey(chatHost)]
queryParams := hostCreds.QueryParameters
if queryParams == nil {
queryParams = make(map[string]string)
}

// Connect via WebSocket
conn, err := chat.Dial(ctx, chatHost, token)
conn, err := chat.Dial(ctx, authCtx.Host, authCtx.Token)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
os.Exit(ExitNetworkError)
}

// Build client options
Expand All @@ -67,7 +58,7 @@ Examples:
opts = append(opts, chat.WithSystemPrompt(systemPrompt))
}

client := chat.NewClient(conn, queryParams, opts...)
client := chat.NewClient(conn, authCtx.QueryParams, opts...)
defer client.Close()

if len(args) > 0 {
Expand Down
28 changes: 26 additions & 2 deletions cmd/cli/cmd/ci.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"encoding/json"
"errors"
"fmt"
"os"
"sync"
Expand Down Expand Up @@ -47,7 +48,11 @@ Exit codes:
ciHost := resolveHost(ctx)
token, err := lib.GetNullifyToken(ctx, ciHost, nullifyToken, githubToken)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: not authenticated\n")
if errors.Is(err, lib.ErrNoToken) {
fmt.Fprintf(os.Stderr, "Error: not authenticated. Run 'nullify auth login' first.\n")
} else {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
}
os.Exit(ExitAuthError)
}

Expand Down Expand Up @@ -159,7 +164,11 @@ var ciReportCmd = &cobra.Command{
ciHost := resolveHost(ctx)
token, err := lib.GetNullifyToken(ctx, ciHost, nullifyToken, githubToken)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: not authenticated\n")
if errors.Is(err, lib.ErrNoToken) {
fmt.Fprintf(os.Stderr, "Error: not authenticated. Run 'nullify auth login' first.\n")
} else {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
}
os.Exit(ExitAuthError)
}

Expand Down Expand Up @@ -189,6 +198,9 @@ var ciReportCmd = &cobra.Command{

rows := make([]reportRow, len(endpoints)*len(severities))
g, gctx := errgroup.WithContext(ctx)
var successCount int64
var apiErrors int64
var mu sync.Mutex

for i, ep := range endpoints {
for j, sev := range severities {
Expand All @@ -202,9 +214,13 @@ var ciReportCmd = &cobra.Command{

body, err := lib.DoGet(gctx, nullifyClient.HttpClient, nullifyClient.BaseURL, ep.path+qs)
if err != nil {
atomic.AddInt64(&apiErrors, 1)
mu.Lock()
fmt.Fprintf(os.Stderr, "Warning: failed to query %s (%s): %v\n", ep.name, sev, err)
mu.Unlock()
return nil
}
atomic.AddInt64(&successCount, 1)

rows[i*len(severities)+j] = reportRow{
scanner: ep.name,
Expand All @@ -218,6 +234,14 @@ var ciReportCmd = &cobra.Command{

_ = g.Wait()

if successCount == 0 {
fmt.Fprintln(os.Stderr, "Error: all API requests failed, cannot generate report")
os.Exit(ExitNetworkError)
}
if apiErrors > 0 {
fmt.Fprintf(os.Stderr, "Warning: %d API requests failed while generating the report\n", apiErrors)
}

fmt.Println("## Nullify Security Report")
fmt.Println()
fmt.Println("| Scanner | Severity | Count |")
Expand Down
18 changes: 18 additions & 0 deletions cmd/cli/cmd/findings.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,24 @@ Auto-detects the current repository from git if --repo is not specified.`,

_ = g.Wait()

successCount := 0
errorCount := 0
for _, result := range results {
if result.Error != "" {
errorCount++
continue
}
successCount++
}

if successCount == 0 {
fmt.Fprintln(os.Stderr, "Error: all scanner requests failed")
os.Exit(ExitNetworkError)
}
if errorCount > 0 {
fmt.Fprintf(os.Stderr, "Warning: %d/%d scanner requests failed\n", errorCount, len(results))
}

out, _ := json.MarshalIndent(results, "", " ")
if err := output.Print(cmd, out); err != nil {
fmt.Fprintln(os.Stderr, string(out))
Expand Down
31 changes: 11 additions & 20 deletions cmd/cli/cmd/mcp.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
package cmd

import (
"errors"
"fmt"
"os"

"strings"

"github.com/nullify-platform/cli/internal/auth"
"github.com/nullify-platform/cli/internal/client"
"github.com/nullify-platform/cli/internal/lib"
"github.com/nullify-platform/cli/internal/mcp"
Expand All @@ -28,26 +28,17 @@ var mcpServeCmd = &cobra.Command{
ctx := setupLogger(cmd.Context())
defer logger.L(ctx).Sync()

mcpHost := resolveHost(ctx)

// Validate that we have a working token before starting the server
if _, err := auth.GetValidToken(ctx, mcpHost); err != nil {
fmt.Fprintf(os.Stderr, "Error: not authenticated. Run 'nullify auth login' first.\n")
os.Exit(ExitAuthError)
}

creds, err := auth.LoadCredentials()
authCtx, err := resolveCommandAuth(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to load credentials: %v\n", err)
os.Exit(1)
if errors.Is(err, lib.ErrNoToken) {
fmt.Fprintf(os.Stderr, "Error: not authenticated. Run 'nullify auth login' first.\n")
} else {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
}
os.Exit(ExitAuthError)
}

hostCreds := creds[auth.CredentialKey(mcpHost)]

queryParams := hostCreds.QueryParameters
if queryParams == nil {
queryParams = make(map[string]string)
}
queryParams := authCtx.QueryParams

// Apply --repo flag or auto-detect from git
repoFlag, _ := cmd.Flags().GetString("repo")
Expand All @@ -74,9 +65,9 @@ var mcpServeCmd = &cobra.Command{

// Create a refreshing client for long-running MCP sessions
tokenProvider := func() (string, error) {
return auth.GetValidToken(ctx, mcpHost)
return lib.GetNullifyToken(ctx, authCtx.Host, nullifyToken, githubToken)
}
nullifyClient, clientErr := client.NewRefreshingNullifyClient(mcpHost, tokenProvider)
nullifyClient, clientErr := client.NewRefreshingNullifyClient(authCtx.Host, tokenProvider)
if clientErr != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create client: %v\n", clientErr)
os.Exit(1)
Expand Down
2 changes: 1 addition & 1 deletion cmd/cli/cmd/pentest.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func init() {
pentestCmd.Flags().String("spec-path", "", "The file path to the OpenAPI file (yaml or json)")
_ = pentestCmd.MarkFlagRequired("spec-path")
pentestCmd.Flags().String("target-host", "", "The base URL of the API to be scanned")
pentestCmd.Flags().StringSlice("header", nil, "Headers for the pentest agent to authenticate with your API")
pentestCmd.Flags().StringSlice("header", nil, "Header for the pentest agent to authenticate with your API. Repeat the flag for multiple headers.")

pentestCmd.Flags().String("github-owner", "", "The GitHub username or organisation")
pentestCmd.Flags().String("github-repo", "", "The repository name for the Nullify issue dashboard")
Expand Down
4 changes: 4 additions & 0 deletions cmd/cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ var rootCmd = &cobra.Command{
if cmd.Name() == "login" || cmd.Name() == "completion" {
return
}

if noColor || os.Getenv("NO_COLOR") != "" {
_ = os.Setenv("NO_COLOR", "1")
}
},
}

Expand Down
39 changes: 39 additions & 0 deletions cmd/cli/cmd/runtime_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package cmd

import (
"context"

"github.com/nullify-platform/cli/internal/auth"
"github.com/nullify-platform/cli/internal/lib"
)

type commandAuthContext struct {
Host string
Token string
QueryParams map[string]string
}

func resolveCommandAuth(ctx context.Context) (*commandAuthContext, error) {
commandHost := resolveHost(ctx)

token, err := lib.GetNullifyToken(ctx, commandHost, nullifyToken, githubToken)
if err != nil {
return nil, err
}

queryParams := map[string]string{}
creds, err := auth.LoadCredentials()
if err == nil {
if hostCreds, ok := creds[auth.CredentialKey(commandHost)]; ok && hostCreds.QueryParameters != nil {
for key, value := range hostCreds.QueryParameters {
queryParams[key] = value
}
}
}

return &commandAuthContext{
Host: commandHost,
Token: token,
QueryParams: queryParams,
}, nil
}
72 changes: 72 additions & 0 deletions cmd/cli/cmd/runtime_auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package cmd

import (
"context"
"testing"
"time"

"github.com/nullify-platform/cli/internal/auth"
"github.com/stretchr/testify/require"
)

func TestResolveCommandAuthUsesEnvTokenWithoutStoredCredentials(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("NULLIFY_HOST", "acme.nullify.ai")
t.Setenv("NULLIFY_TOKEN", "env-token")

originalHost := host
originalNullifyToken := nullifyToken
originalGithubToken := githubToken
host = ""
nullifyToken = ""
githubToken = ""
t.Cleanup(func() {
host = originalHost
nullifyToken = originalNullifyToken
githubToken = originalGithubToken
})

authCtx, err := resolveCommandAuth(setupLogger(context.Background()))
require.NoError(t, err)
require.Equal(t, "acme.nullify.ai", authCtx.Host)
require.Equal(t, "env-token", authCtx.Token)
require.Empty(t, authCtx.QueryParams)
}

func TestResolveCommandAuthClonesStoredQueryParams(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("NULLIFY_HOST", "acme.nullify.ai")
t.Setenv("NULLIFY_TOKEN", "env-token")

err := auth.SaveHostCredentials("acme.nullify.ai", auth.HostCredentials{
AccessToken: "stored-token",
RefreshToken: "refresh-token",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
QueryParameters: map[string]string{
"githubOwnerId": "123",
},
})
require.NoError(t, err)

originalHost := host
originalNullifyToken := nullifyToken
originalGithubToken := githubToken
host = ""
nullifyToken = ""
githubToken = ""
t.Cleanup(func() {
host = originalHost
nullifyToken = originalNullifyToken
githubToken = originalGithubToken
})

authCtx, err := resolveCommandAuth(setupLogger(context.Background()))
require.NoError(t, err)
require.Equal(t, map[string]string{"githubOwnerId": "123"}, authCtx.QueryParams)

authCtx.QueryParams["githubOwnerId"] = "456"

creds, err := auth.LoadCredentials()
require.NoError(t, err)
require.Equal(t, "123", creds[auth.CredentialKey("acme.nullify.ai")].QueryParameters["githubOwnerId"])
}
Loading