Skip to content

Commit e87685b

Browse files
committed
Fix correctness, safety, and UX issues across 7 areas
- Scan duration: add upper bound (24h) and lower bound (1s) validation - init.go: replace silent fmt.Sscanf failures with strconv.Atoi - init.go: include error reason in skipped ingress/NetworkPolicy messages - init.go: add stderr warnings when namespace discovery fails - graph.go: validate --output path parent directory before processing - define.go: add Cobra mutual exclusivity and one-required constraints - gadget.go: refactor with dependency injection (CommandRunner) for testability, extract buildArgs() and calculateTimeout() helpers, convert check functions to methods with backward-compat wrappers
1 parent 5acda48 commit e87685b

9 files changed

Lines changed: 637 additions & 59 deletions

File tree

pkg/cli/define.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ func init() {
7777
defineCmd.Flags().BoolVarP(&defineInteractive, "interactive", "i", false, "Interactive policy creation")
7878
defineCmd.Flags().StringVar(&defineValidate, "validate", "", "Validate a policy file")
7979
defineCmd.Flags().StringVar(&defineMerge, "merge", "", "Merge multiple policy files (comma-separated)")
80+
81+
defineCmd.MarkFlagsMutuallyExclusive("validate", "merge")
82+
defineCmd.MarkFlagsMutuallyExclusive("validate", "from")
83+
defineCmd.MarkFlagsMutuallyExclusive("validate", "interactive")
84+
defineCmd.MarkFlagsMutuallyExclusive("merge", "from")
85+
defineCmd.MarkFlagsMutuallyExclusive("merge", "interactive")
86+
// --from + --interactive is intentionally allowed (interactive modifies --from behavior)
87+
defineCmd.MarkFlagsOneRequired("from", "interactive", "validate", "merge")
8088
}
8189

8290
func runDefine(cmd *cobra.Command, args []string) error {
@@ -103,6 +111,8 @@ func runDefine(cmd *cobra.Command, args []string) error {
103111
return defineInteractively(defineOutput)
104112
}
105113

114+
// MarkFlagsOneRequired ensures at least one mode flag is set,
115+
// so this is unreachable under normal usage.
106116
return fmt.Errorf("specify --from, --interactive, --validate, or --merge")
107117
}
108118

pkg/cli/define_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package cli
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/spf13/pflag"
8+
)
9+
10+
func TestDefineFlags_MutualExclusivity(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
args []string
14+
wantErr string
15+
}{
16+
{
17+
name: "validate and merge are mutually exclusive",
18+
args: []string{"define", "--validate", "p.yaml", "--merge", "a.yaml,b.yaml"},
19+
wantErr: "if any flags in the group [validate merge] are set none of the others can be",
20+
},
21+
{
22+
name: "validate and from are mutually exclusive",
23+
args: []string{"define", "--validate", "p.yaml", "--from", "obs.yaml"},
24+
wantErr: "if any flags in the group [validate from] are set none of the others can be",
25+
},
26+
{
27+
name: "validate and interactive are mutually exclusive",
28+
args: []string{"define", "--validate", "p.yaml", "--interactive"},
29+
wantErr: "if any flags in the group [validate interactive] are set none of the others can be",
30+
},
31+
{
32+
name: "merge and from are mutually exclusive",
33+
args: []string{"define", "--merge", "a.yaml,b.yaml", "--from", "obs.yaml"},
34+
wantErr: "if any flags in the group [merge from] are set none of the others can be",
35+
},
36+
{
37+
name: "merge and interactive are mutually exclusive",
38+
args: []string{"define", "--merge", "a.yaml,b.yaml", "--interactive"},
39+
wantErr: "if any flags in the group [merge interactive] are set none of the others can be",
40+
},
41+
{
42+
name: "from and interactive are allowed together",
43+
args: []string{"define", "--from", "obs.yaml", "--interactive"},
44+
// This combination is intentionally allowed; the error here would be from
45+
// trying to actually load the file, not from flag validation.
46+
wantErr: "",
47+
},
48+
{
49+
name: "no mode flags produces error",
50+
args: []string{"define"},
51+
wantErr: "at least one of the flags",
52+
},
53+
}
54+
55+
for _, tt := range tests {
56+
t.Run(tt.name, func(t *testing.T) {
57+
// Reset all define flag variables to defaults before each test
58+
defineFrom = ""
59+
defineBase = ""
60+
defineOutput = "policy.yaml"
61+
defineInteractive = false
62+
defineValidate = ""
63+
defineMerge = ""
64+
65+
// Reset the cobra flag state so mutual exclusivity checks work correctly
66+
defineCmd.Flags().VisitAll(func(f *pflag.Flag) {
67+
f.Changed = false
68+
})
69+
70+
rootCmd.SetArgs(tt.args)
71+
err := rootCmd.Execute()
72+
73+
if tt.wantErr == "" {
74+
// We expect no flag-level error. The command may fail later
75+
// (e.g. file not found), but not from flag validation.
76+
if err != nil && strings.Contains(err.Error(), "if any flags in the group") {
77+
t.Errorf("unexpected flag exclusivity error: %v", err)
78+
}
79+
if err != nil && strings.Contains(err.Error(), "at least one of the flags") {
80+
t.Errorf("unexpected required flag error: %v", err)
81+
}
82+
return
83+
}
84+
85+
if err == nil {
86+
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
87+
}
88+
if !strings.Contains(err.Error(), tt.wantErr) {
89+
t.Errorf("error %q does not contain %q", err.Error(), tt.wantErr)
90+
}
91+
})
92+
}
93+
}

pkg/cli/graph.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,29 @@ func init() {
4949
graphCmd.MarkFlagRequired("policy")
5050
}
5151

52+
func validateOutputPath(path string) error {
53+
dir := filepath.Dir(filepath.Clean(path))
54+
info, err := os.Stat(dir)
55+
if err != nil {
56+
if os.IsNotExist(err) {
57+
return fmt.Errorf("output directory %q does not exist", dir)
58+
}
59+
return fmt.Errorf("cannot access output directory %q: %w", dir, err)
60+
}
61+
if !info.IsDir() {
62+
return fmt.Errorf("output path parent %q is not a directory", dir)
63+
}
64+
return nil
65+
}
66+
5267
func runGraph(cmd *cobra.Command, args []string) error {
68+
// Validate output path early
69+
if graphOutput != "" {
70+
if err := validateOutputPath(graphOutput); err != nil {
71+
return err
72+
}
73+
}
74+
5375
// Load policy
5476
pol, err := policy.LoadFromFile(graphPolicyFile)
5577
if err != nil {

pkg/cli/graph_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,50 @@ func TestGraphCommand_WriteToFile(t *testing.T) {
148148
})
149149
}
150150

151+
func TestValidateOutputPath(t *testing.T) {
152+
tmpDir := t.TempDir()
153+
154+
t.Run("valid temp dir path", func(t *testing.T) {
155+
path := filepath.Join(tmpDir, "output.dot")
156+
if err := validateOutputPath(path); err != nil {
157+
t.Errorf("unexpected error: %v", err)
158+
}
159+
})
160+
161+
t.Run("current dir path", func(t *testing.T) {
162+
if err := validateOutputPath("output.dot"); err != nil {
163+
t.Errorf("unexpected error: %v", err)
164+
}
165+
})
166+
167+
t.Run("nonexistent parent dir", func(t *testing.T) {
168+
path := filepath.Join(tmpDir, "no-such-dir", "output.dot")
169+
err := validateOutputPath(path)
170+
if err == nil {
171+
t.Fatal("expected error for nonexistent parent dir")
172+
}
173+
if !strings.Contains(err.Error(), "does not exist") {
174+
t.Errorf("error %q should mention 'does not exist'", err.Error())
175+
}
176+
})
177+
178+
t.Run("file as parent", func(t *testing.T) {
179+
// Create a file to use as a fake parent
180+
fakePath := filepath.Join(tmpDir, "fakefile")
181+
if err := os.WriteFile(fakePath, []byte("x"), 0644); err != nil {
182+
t.Fatal(err)
183+
}
184+
path := filepath.Join(fakePath, "output.dot")
185+
err := validateOutputPath(path)
186+
if err == nil {
187+
t.Fatal("expected error when parent is a file")
188+
}
189+
if !strings.Contains(err.Error(), "not a directory") {
190+
t.Errorf("error %q should mention 'not a directory'", err.Error())
191+
}
192+
})
193+
}
194+
151195
func min(a, b int) int {
152196
if a < b {
153197
return a

pkg/cli/init.go

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"os"
88
"sort"
9+
"strconv"
910
"strings"
1011
"time"
1112

@@ -121,7 +122,7 @@ func runInit(cmd *cobra.Command, args []string) error {
121122
fmt.Printf("%s Discovering ingresses... ", colorInfo("→"))
122123
ingresses, err := discoverIngresses(ctx, namespaces)
123124
if err != nil {
124-
fmt.Println(colorWarning("⚠"), colorDim("(skipped)"))
125+
fmt.Println(colorWarning("⚠"), colorDim(fmt.Sprintf("(skipped: %s)", err)))
125126
} else {
126127
fmt.Println(colorSuccess("✓"), colorDim(fmt.Sprintf("(%d found)", len(ingresses))))
127128
}
@@ -130,7 +131,7 @@ func runInit(cmd *cobra.Command, args []string) error {
130131
fmt.Printf("%s Checking existing NetworkPolicies... ", colorInfo("→"))
131132
existingPolicies, err := discoverNetworkPolicies(ctx, namespaces)
132133
if err != nil {
133-
fmt.Println(colorWarning("⚠"), colorDim("(skipped)"))
134+
fmt.Println(colorWarning("⚠"), colorDim(fmt.Sprintf("(skipped: %s)", err)))
134135
} else {
135136
fmt.Println(colorSuccess("✓"), colorDim(fmt.Sprintf("(%d found)", len(existingPolicies))))
136137
}
@@ -272,6 +273,7 @@ func discoverWorkloads(ctx context.Context, namespaces []string) (*workloadInfo,
272273
"-o", `jsonpath={range .items[*]}{.metadata.labels.app\.kubernetes\.io/name}{"|"}{.metadata.labels.app}{"|"}{range .spec.containers[*]}{range .ports[*]}{.containerPort}{" "}{end}{end}{"\n"}{end}`,
273274
}, nil)
274275
if err != nil {
276+
fmt.Fprintf(os.Stderr, " %s namespace %s: %s\n", colorWarning("⚠"), ns, err)
275277
continue
276278
}
277279

@@ -305,8 +307,10 @@ func discoverWorkloads(ctx context.Context, namespaces []string) (*workloadInfo,
305307
// Parse ports
306308
if len(parts) > 2 {
307309
for _, p := range strings.Fields(parts[2]) {
308-
var port int
309-
fmt.Sscanf(p, "%d", &port)
310+
port, err := strconv.Atoi(strings.TrimSpace(p))
311+
if err != nil {
312+
continue
313+
}
310314
if port > 0 {
311315
app.ports = appendUnique(app.ports, port)
312316
}
@@ -321,6 +325,7 @@ func discoverWorkloads(ctx context.Context, namespaces []string) (*workloadInfo,
321325
"-o", `jsonpath={range .items[*]}{.spec.selector.app\.kubernetes\.io/name}{"|"}{.spec.selector.app}{"|"}{range .spec.ports[*]}{.port}{" "}{end}{"\n"}{end}`,
322326
}, nil)
323327
if err != nil {
328+
fmt.Fprintf(os.Stderr, " %s namespace %s: %s\n", colorWarning("⚠"), ns, err)
324329
continue
325330
}
326331

@@ -354,8 +359,10 @@ func discoverWorkloads(ctx context.Context, namespaces []string) (*workloadInfo,
354359
// Parse ports from service
355360
if len(parts) > 2 {
356361
for _, p := range strings.Fields(parts[2]) {
357-
var port int
358-
fmt.Sscanf(p, "%d", &port)
362+
port, err := strconv.Atoi(strings.TrimSpace(p))
363+
if err != nil {
364+
continue
365+
}
359366
if port > 0 {
360367
app.ports = appendUnique(app.ports, port)
361368
}
@@ -385,6 +392,7 @@ func discoverIngresses(ctx context.Context, namespaces []string) ([]ingressInfo,
385392
"-o", "jsonpath={range .items[*]}{.metadata.name}|{.spec.rules[*].host}|{.spec.rules[*].http.paths[*].backend.service.name}|{.spec.rules[*].http.paths[*].backend.service.port.number}\\n{end}",
386393
}, nil)
387394
if err != nil {
395+
fmt.Fprintf(os.Stderr, " %s namespace %s: %s\n", colorWarning("⚠"), ns, err)
388396
continue
389397
}
390398

@@ -404,7 +412,9 @@ func discoverIngresses(ctx context.Context, namespaces []string) ([]ingressInfo,
404412
serviceName: parts[2],
405413
}
406414
if len(parts) > 3 {
407-
fmt.Sscanf(parts[3], "%d", &ing.servicePort)
415+
if port, err := strconv.Atoi(strings.TrimSpace(parts[3])); err == nil {
416+
ing.servicePort = port
417+
}
408418
}
409419
ingresses = append(ingresses, ing)
410420
}
@@ -422,6 +432,7 @@ func discoverNetworkPolicies(ctx context.Context, namespaces []string) ([]string
422432
"-o", "jsonpath={.items[*].metadata.name}",
423433
}, nil)
424434
if err != nil {
435+
fmt.Fprintf(os.Stderr, " %s namespace %s: %s\n", colorWarning("⚠"), ns, err)
425436
continue
426437
}
427438
for _, name := range strings.Fields(result.Stdout) {

pkg/cli/scan.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,11 @@ func runScan(cmd *cobra.Command, args []string) error {
6868
return err
6969
}
7070

71-
// Parse duration
71+
// Parse and validate duration
7272
var err error
73-
duration, err = time.ParseDuration(scanDuration)
73+
duration, err = validateScanDuration(scanDuration)
7474
if err != nil {
75-
return fmt.Errorf("invalid duration format: %w", err)
75+
return err
7676
}
7777

7878
fmt.Printf("Starting network scan...\n")
@@ -172,6 +172,20 @@ func runScan(cmd *cobra.Command, args []string) error {
172172
return nil
173173
}
174174

175+
func validateScanDuration(s string) (time.Duration, error) {
176+
d, err := time.ParseDuration(s)
177+
if err != nil {
178+
return 0, fmt.Errorf("invalid duration format: %w", err)
179+
}
180+
if d > 24*time.Hour {
181+
return 0, fmt.Errorf("scan duration %s exceeds maximum of 24h", d)
182+
}
183+
if d < 1*time.Second {
184+
return 0, fmt.Errorf("scan duration must be at least 1s")
185+
}
186+
return d, nil
187+
}
188+
175189
func splitNamespaces(ns string) []string {
176190
if ns == "" {
177191
return nil

0 commit comments

Comments
 (0)