Skip to content

Commit 685b4c1

Browse files
feat: add output formatting, enhanced MCP tools, unit tests, and security fixes
- Add output formatting package (json/yaml/table) with --output flag support - Update code generator to use output.Print() for all 326 generated commands - Add composite MCP tools: get_security_posture_summary, get_findings_for_repo - Add triage tools for SCA, secrets, and DAST findings - Add --repo flag to MCP serve with git remote auto-detection - Add unit tests for auth, MCP server helpers, output formatters, API client - Fix: config file permissions changed from 0644 to 0600 - Fix: add HTTP status code check in device token polling - Fix: show manual URL message when browser can't be opened - Fix: remove token value from debug log output Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4e23375 commit 685b4c1

54 files changed

Lines changed: 29772 additions & 10 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎cmd/cli/cmd/auth.go‎

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
"time"
9+
10+
"github.com/nullify-platform/cli/internal/auth"
11+
"github.com/nullify-platform/cli/internal/lib"
12+
"github.com/nullify-platform/logger/pkg/logger"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
var authCmd = &cobra.Command{
17+
Use: "auth",
18+
Short: "Manage authentication",
19+
Long: "Authenticate with the Nullify API using device flow, manage credentials and tokens.",
20+
}
21+
22+
var loginCmd = &cobra.Command{
23+
Use: "login",
24+
Short: "Log in to Nullify",
25+
Long: "Authenticate with your Nullify instance using device flow authentication.",
26+
Run: func(cmd *cobra.Command, args []string) {
27+
ctx := setupLogger()
28+
defer logger.L(ctx).Sync()
29+
30+
loginHost := host
31+
32+
// If no host from flag, try config file
33+
if loginHost == "" {
34+
cfg, err := auth.LoadConfig()
35+
if err == nil && cfg.Host != "" {
36+
loginHost = cfg.Host
37+
}
38+
}
39+
40+
// If still no host, prompt
41+
if loginHost == "" {
42+
fmt.Print("Enter your Nullify instance (e.g., api.acme.nullify.ai): ")
43+
fmt.Scanln(&loginHost)
44+
}
45+
46+
sanitizedHost, err := lib.SanitizeNullifyHost(loginHost)
47+
if err != nil {
48+
fmt.Fprintf(os.Stderr, "Error: invalid host %q - must be in the format api.<your-instance>.nullify.ai\n", loginHost)
49+
os.Exit(1)
50+
}
51+
52+
err = auth.DeviceFlowLogin(ctx, sanitizedHost)
53+
if err != nil {
54+
fmt.Fprintf(os.Stderr, "Error: login failed: %v\n", err)
55+
os.Exit(1)
56+
}
57+
},
58+
}
59+
60+
var logoutCmd = &cobra.Command{
61+
Use: "logout",
62+
Short: "Log out of Nullify",
63+
Long: "Clear stored credentials for the current or specified host.",
64+
Run: func(cmd *cobra.Command, args []string) {
65+
ctx := setupLogger()
66+
defer logger.L(ctx).Sync()
67+
68+
logoutHost := resolveHostForAuth(ctx)
69+
70+
err := auth.Logout(logoutHost)
71+
if err != nil {
72+
fmt.Fprintf(os.Stderr, "Error: logout failed: %v\n", err)
73+
os.Exit(1)
74+
}
75+
76+
fmt.Printf("Logged out from %s\n", logoutHost)
77+
},
78+
}
79+
80+
var statusCmd = &cobra.Command{
81+
Use: "status",
82+
Short: "Show authentication status",
83+
Long: "Display the current authentication state including host, user, and token expiry.",
84+
Run: func(cmd *cobra.Command, args []string) {
85+
cfg, err := auth.LoadConfig()
86+
if err != nil {
87+
fmt.Println("Not configured. Run 'nullify auth login --host api.<your-instance>.nullify.ai' to get started.")
88+
return
89+
}
90+
91+
if cfg.Host == "" {
92+
fmt.Println("No host configured. Run 'nullify auth login --host api.<your-instance>.nullify.ai'")
93+
return
94+
}
95+
96+
fmt.Printf("Host: %s\n", cfg.Host)
97+
98+
creds, err := auth.LoadCredentials()
99+
if err != nil {
100+
fmt.Println("Status: not authenticated")
101+
return
102+
}
103+
104+
hostCreds, ok := creds[cfg.Host]
105+
if !ok {
106+
fmt.Println("Status: not authenticated")
107+
return
108+
}
109+
110+
if hostCreds.ExpiresAt > 0 {
111+
expiresAt := time.Unix(hostCreds.ExpiresAt, 0)
112+
if time.Now().After(expiresAt) {
113+
fmt.Println("Status: token expired (will auto-refresh on next command)")
114+
} else {
115+
fmt.Printf("Status: authenticated (expires %s)\n", expiresAt.Format(time.RFC3339))
116+
}
117+
} else {
118+
fmt.Println("Status: authenticated")
119+
}
120+
},
121+
}
122+
123+
var tokenCmd = &cobra.Command{
124+
Use: "token",
125+
Short: "Print access token to stdout",
126+
Long: "Print the current access token. Useful for piping to other tools.",
127+
Run: func(cmd *cobra.Command, args []string) {
128+
ctx := setupLogger()
129+
defer logger.L(ctx).Sync()
130+
131+
hostForToken := resolveHostForAuth(ctx)
132+
133+
token, err := auth.GetValidToken(ctx, hostForToken)
134+
if err != nil {
135+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
136+
os.Exit(1)
137+
}
138+
139+
fmt.Print(token)
140+
},
141+
}
142+
143+
var switchCmd = &cobra.Command{
144+
Use: "switch",
145+
Short: "Switch between configured hosts",
146+
Long: "Switch the default host when multiple Nullify instances are configured.",
147+
Run: func(cmd *cobra.Command, args []string) {
148+
switchHost, _ := cmd.Flags().GetString("host")
149+
if switchHost == "" && host != "" {
150+
switchHost = host
151+
}
152+
153+
if switchHost == "" {
154+
// List available hosts
155+
creds, err := auth.LoadCredentials()
156+
if err != nil || len(creds) == 0 {
157+
fmt.Println("No configured hosts. Run 'nullify auth login --host api.<your-instance>.nullify.ai'")
158+
return
159+
}
160+
161+
cfg, _ := auth.LoadConfig()
162+
fmt.Println("Configured hosts:")
163+
for h := range creds {
164+
marker := " "
165+
if cfg != nil && cfg.Host == h {
166+
marker = "* "
167+
}
168+
fmt.Printf("%s%s\n", marker, h)
169+
}
170+
fmt.Println("\nUse 'nullify auth switch --host <host>' to switch.")
171+
return
172+
}
173+
174+
sanitizedHost, err := lib.SanitizeNullifyHost(switchHost)
175+
if err != nil {
176+
fmt.Fprintf(os.Stderr, "Error: invalid host %q\n", switchHost)
177+
os.Exit(1)
178+
}
179+
180+
cfg, err := auth.LoadConfig()
181+
if err != nil {
182+
cfg = &auth.Config{}
183+
}
184+
cfg.Host = sanitizedHost
185+
186+
err = auth.SaveConfig(cfg)
187+
if err != nil {
188+
fmt.Fprintf(os.Stderr, "Error: failed to save config: %v\n", err)
189+
os.Exit(1)
190+
}
191+
192+
fmt.Printf("Switched to %s\n", sanitizedHost)
193+
},
194+
}
195+
196+
var configShowCmd = &cobra.Command{
197+
Use: "config",
198+
Short: "Show current configuration",
199+
Long: "Display the current CLI configuration as JSON.",
200+
Run: func(cmd *cobra.Command, args []string) {
201+
cfg, err := auth.LoadConfig()
202+
if err != nil {
203+
fmt.Println("{}")
204+
return
205+
}
206+
207+
data, _ := json.MarshalIndent(cfg, "", " ")
208+
fmt.Println(string(data))
209+
},
210+
}
211+
212+
func init() {
213+
rootCmd.AddCommand(authCmd)
214+
authCmd.AddCommand(loginCmd)
215+
authCmd.AddCommand(logoutCmd)
216+
authCmd.AddCommand(statusCmd)
217+
authCmd.AddCommand(tokenCmd)
218+
authCmd.AddCommand(switchCmd)
219+
authCmd.AddCommand(configShowCmd)
220+
}
221+
222+
func resolveHostForAuth(ctx context.Context) string {
223+
if host != "" {
224+
sanitized, err := lib.SanitizeNullifyHost(host)
225+
if err != nil {
226+
fmt.Fprintf(os.Stderr, "Error: invalid host %q\n", host)
227+
os.Exit(1)
228+
}
229+
return sanitized
230+
}
231+
232+
cfg, err := auth.LoadConfig()
233+
if err == nil && cfg.Host != "" {
234+
return cfg.Host
235+
}
236+
237+
fmt.Fprintln(os.Stderr, "Error: no host configured. Use --host or run 'nullify auth login'")
238+
os.Exit(1)
239+
return ""
240+
}

‎cmd/cli/cmd/completion.go‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package cmd
2+
3+
import (
4+
"os"
5+
6+
"github.com/spf13/cobra"
7+
)
8+
9+
var completionCmd = &cobra.Command{
10+
Use: "completion [bash|zsh|fish|powershell]",
11+
Short: "Generate shell completion scripts",
12+
Long: `Generate shell completion scripts for the Nullify CLI.
13+
14+
To load completions:
15+
16+
Bash:
17+
$ source <(nullify completion bash)
18+
# To load completions for each session, execute once:
19+
# Linux:
20+
$ nullify completion bash > /etc/bash_completion.d/nullify
21+
# macOS:
22+
$ nullify completion bash > $(brew --prefix)/etc/bash_completion.d/nullify
23+
24+
Zsh:
25+
$ source <(nullify completion zsh)
26+
# To load completions for each session, execute once:
27+
$ nullify completion zsh > "${fpath[1]}/_nullify"
28+
29+
Fish:
30+
$ nullify completion fish | source
31+
# To load completions for each session, execute once:
32+
$ nullify completion fish > ~/.config/fish/completions/nullify.fish
33+
34+
PowerShell:
35+
PS> nullify completion powershell | Out-String | Invoke-Expression
36+
`,
37+
DisableFlagsInUseLine: true,
38+
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
39+
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
40+
Run: func(cmd *cobra.Command, args []string) {
41+
switch args[0] {
42+
case "bash":
43+
rootCmd.GenBashCompletion(os.Stdout)
44+
case "zsh":
45+
rootCmd.GenZshCompletion(os.Stdout)
46+
case "fish":
47+
rootCmd.GenFishCompletion(os.Stdout, true)
48+
case "powershell":
49+
rootCmd.GenPowerShellCompletionWithDesc(os.Stdout)
50+
}
51+
},
52+
}
53+
54+
func init() {
55+
rootCmd.AddCommand(completionCmd)
56+
}

‎cmd/cli/cmd/dast.go‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package cmd
2+
3+
import (
4+
"os"
5+
6+
"github.com/nullify-platform/cli/internal/dast"
7+
"github.com/nullify-platform/logger/pkg/logger"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
var dastCmd = &cobra.Command{
12+
Use: "dast",
13+
Short: "Test the given app for bugs and vulnerabilities",
14+
Long: "Run DAST (Dynamic Application Security Testing) scans against your API endpoints.",
15+
Run: func(cmd *cobra.Command, args []string) {
16+
ctx := setupLogger()
17+
defer logger.L(ctx).Sync()
18+
19+
dastArgs := getDastArgs(cmd)
20+
21+
if dastArgs.Path == "" {
22+
cmd.Help()
23+
return
24+
}
25+
26+
nullifyClient := getNullifyClient(ctx)
27+
28+
err := dast.RunDASTScan(ctx, dastArgs, nullifyClient, getLogLevel())
29+
if err != nil {
30+
logger.L(ctx).Error(
31+
"failed to run dast scan",
32+
logger.Err(err),
33+
)
34+
os.Exit(1)
35+
}
36+
},
37+
}
38+
39+
func init() {
40+
rootCmd.AddCommand(dastCmd)
41+
42+
dastCmd.Flags().String("app-name", "", "The unique name of the app to be scanned")
43+
dastCmd.Flags().String("spec-path", "", "The file path to the OpenAPI file (yaml or json)")
44+
dastCmd.Flags().String("target-host", "", "The base URL of the API to be scanned")
45+
dastCmd.Flags().StringSlice("header", nil, "Headers for the DAST agent to authenticate with your API")
46+
47+
dastCmd.Flags().String("github-owner", "", "The GitHub username or organisation")
48+
dastCmd.Flags().String("github-repo", "", "The repository name for the Nullify issue dashboard")
49+
50+
dastCmd.Flags().Bool("local", false, "Test the given app locally for bugs and vulnerabilities in private networks")
51+
dastCmd.Flags().String("image-label", "latest", "Version of the DAST local image used for scanning")
52+
dastCmd.Flags().Bool("force-pull", false, "Force a docker pull of the latest DAST local image")
53+
dastCmd.Flags().Bool("use-host-network", false, "Use the host network for the DAST local scan")
54+
55+
dastCmd.Flags().String("dast-auth-config", "", "The path to the DAST auth config file")
56+
}
57+
58+
func getDastArgs(cmd *cobra.Command) *dast.DAST {
59+
appName, _ := cmd.Flags().GetString("app-name")
60+
specPath, _ := cmd.Flags().GetString("spec-path")
61+
targetHost, _ := cmd.Flags().GetString("target-host")
62+
headers, _ := cmd.Flags().GetStringSlice("header")
63+
githubOwner, _ := cmd.Flags().GetString("github-owner")
64+
githubRepo, _ := cmd.Flags().GetString("github-repo")
65+
local, _ := cmd.Flags().GetBool("local")
66+
imageLabel, _ := cmd.Flags().GetString("image-label")
67+
forcePull, _ := cmd.Flags().GetBool("force-pull")
68+
useHostNetwork, _ := cmd.Flags().GetBool("use-host-network")
69+
dastAuthConfig, _ := cmd.Flags().GetString("dast-auth-config")
70+
71+
return &dast.DAST{
72+
AppName: appName,
73+
Path: specPath,
74+
TargetHost: targetHost,
75+
AuthHeaders: headers,
76+
GitHubOwner: githubOwner,
77+
GitHubRepository: githubRepo,
78+
Local: local,
79+
ImageLabel: imageLabel,
80+
ForcePullImage: forcePull,
81+
UseHostNetwork: useHostNetwork,
82+
AuthConfig: dastAuthConfig,
83+
}
84+
}

0 commit comments

Comments
 (0)