Skip to content

Commit d0d7aa9

Browse files
committed
Fix error handling and add namespace validation
- Handle ParseSelector errors in comparator.go instead of silently ignoring - Collect and display selector parse errors in checker.go and build.go - Use defer for cleanup in nca.go exportClusterState to ensure cleanup on all error paths - Wrap WriteFile error in init.go writePolicyWithHeader with file path context - Add namespace name validation (RFC 1123) in pkg/util/validate.go - Integrate validation in init, scan, and gadget to reject invalid namespaces early
1 parent e87685b commit d0d7aa9

10 files changed

Lines changed: 167 additions & 28 deletions

File tree

pkg/build/checker.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ type SelectorWarning struct {
2323

2424
// CheckResult holds all warnings from selector checking.
2525
type CheckResult struct {
26-
Warnings []SelectorWarning
26+
Warnings []SelectorWarning
27+
ParseErrors []string // Selectors that couldn't be parsed
2728
}
2829

2930
// SelectorChecker validates policy selectors against live cluster state.
@@ -51,6 +52,7 @@ func (sc *SelectorChecker) Check(ctx context.Context, pol *policy.Policy) (*Chec
5152
direction string
5253
}
5354
refs := make(map[dedupeKey]*selectorRef)
55+
result := &CheckResult{}
5456

5557
for _, rule := range pol.Rules {
5658
if rule.Action == policy.ActionDeny {
@@ -69,7 +71,12 @@ func (sc *SelectorChecker) Check(ctx context.Context, pol *policy.Policy) (*Chec
6971
}
7072

7173
parsed, err := policy.ParseSelector(dir.raw)
72-
if err != nil || parsed == nil {
74+
if err != nil {
75+
result.ParseErrors = append(result.ParseErrors,
76+
fmt.Sprintf("rule %q: invalid %s selector %q: %v", rule.Name, dir.direction, dir.raw, err))
77+
continue
78+
}
79+
if parsed == nil {
7380
continue
7481
}
7582

@@ -104,9 +111,9 @@ func (sc *SelectorChecker) Check(ctx context.Context, pol *policy.Policy) (*Chec
104111
namespacesNeeded[ns] = true
105112
}
106113

107-
// If no namespaces to check, return early
114+
// If no namespaces to check, return early (but preserve any parse errors)
108115
if len(namespacesNeeded) == 0 {
109-
return &CheckResult{}, nil
116+
return result, nil
110117
}
111118

112119
// Fetch cluster namespaces
@@ -217,7 +224,8 @@ func (sc *SelectorChecker) Check(ctx context.Context, pol *policy.Policy) (*Chec
217224
}
218225
}
219226

220-
return &CheckResult{Warnings: warnings}, nil
227+
result.Warnings = warnings
228+
return result, nil
221229
}
222230

223231
// fetchNamespaces retrieves all namespace names from the cluster.

pkg/cli/build.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,22 @@ func runBuild(cmd *cobra.Command, args []string) error {
9494
checkResult, err := checker.Check(ctx, pol)
9595
if err != nil {
9696
fmt.Fprintf(os.Stderr, "%s Could not check selectors: %s\n\n", colorWarning("⚠"), err)
97-
} else if len(checkResult.Warnings) > 0 {
98-
fmt.Fprintf(os.Stderr, "%s %d selector warning(s):\n", colorWarning("⚠"), len(checkResult.Warnings))
99-
for _, w := range checkResult.Warnings {
100-
fmt.Fprintf(os.Stderr, " %s rule %q %s selector %q: %s\n",
101-
colorDim("•"), w.RuleName, w.Direction, w.Selector, w.Reason)
97+
} else {
98+
if len(checkResult.ParseErrors) > 0 {
99+
fmt.Fprintf(os.Stderr, "%s Selector parse errors:\n", colorWarning("⚠"))
100+
for _, e := range checkResult.ParseErrors {
101+
fmt.Fprintf(os.Stderr, " %s\n", e)
102+
}
103+
fmt.Fprintln(os.Stderr)
104+
}
105+
if len(checkResult.Warnings) > 0 {
106+
fmt.Fprintf(os.Stderr, "%s %d selector warning(s):\n", colorWarning("⚠"), len(checkResult.Warnings))
107+
for _, w := range checkResult.Warnings {
108+
fmt.Fprintf(os.Stderr, " %s rule %q %s selector %q: %s\n",
109+
colorDim("•"), w.RuleName, w.Direction, w.Selector, w.Reason)
110+
}
111+
fmt.Fprintln(os.Stderr)
102112
}
103-
fmt.Fprintln(os.Stderr)
104113
}
105114
}
106115

pkg/cli/init.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,11 @@ func discoverNamespaces(ctx context.Context, filter string, h *heuristics.Config
226226
if filter != "" {
227227
wanted := make(map[string]bool)
228228
for _, ns := range strings.Split(filter, ",") {
229-
wanted[strings.TrimSpace(ns)] = true
229+
ns = strings.TrimSpace(ns)
230+
if err := util.ValidateNamespaceName(ns); err != nil {
231+
return nil, fmt.Errorf("invalid namespace in filter: %w", err)
232+
}
233+
wanted[ns] = true
230234
}
231235
var filtered []string
232236
for _, ns := range all {
@@ -906,5 +910,8 @@ func writePolicyWithHeader(pol *policy.Policy, path string) error {
906910
header += "#\n"
907911
header += "# Run: netalchemy verify -p policy.yaml\n\n"
908912

909-
return os.WriteFile(path, []byte(header+string(yamlData)), 0600)
913+
if err := os.WriteFile(path, []byte(header+string(yamlData)), 0600); err != nil {
914+
return fmt.Errorf("failed to write policy to %s: %w", path, err)
915+
}
916+
return nil
910917
}

pkg/cli/scan.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/fenio/netalchemy/pkg/policy"
1010
"github.com/fenio/netalchemy/pkg/scan"
11+
"github.com/fenio/netalchemy/pkg/util"
1112
"github.com/spf13/cobra"
1213
)
1314

@@ -109,8 +110,12 @@ func runScan(cmd *cobra.Command, args []string) error {
109110
}
110111

111112
// Convert trace to rules
113+
sourceNamespaces, err := splitNamespaces(scanNamespaces)
114+
if err != nil {
115+
return fmt.Errorf("invalid namespace: %w", err)
116+
}
112117
converter := &scan.TraceConverter{
113-
SourceNamespaces: splitNamespaces(scanNamespaces),
118+
SourceNamespaces: sourceNamespaces,
114119
}
115120

116121
result, err := converter.ConvertTraceWithResult(trace)
@@ -186,16 +191,20 @@ func validateScanDuration(s string) (time.Duration, error) {
186191
return d, nil
187192
}
188193

189-
func splitNamespaces(ns string) []string {
194+
func splitNamespaces(ns string) ([]string, error) {
190195
if ns == "" {
191-
return nil
196+
return nil, nil
192197
}
193198
var result []string
194199
for _, n := range strings.Split(ns, ",") {
195200
trimmed := strings.TrimSpace(n)
196-
if trimmed != "" {
197-
result = append(result, trimmed)
201+
if trimmed == "" {
202+
continue
203+
}
204+
if err := util.ValidateNamespaceName(trimmed); err != nil {
205+
return nil, err
198206
}
207+
result = append(result, trimmed)
199208
}
200-
return result
209+
return result, nil
201210
}

pkg/cli/scan_test.go

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ import (
99

1010
func TestSplitNamespaces(t *testing.T) {
1111
tests := []struct {
12-
name string
13-
input string
14-
want []string
12+
name string
13+
input string
14+
want []string
15+
wantErr bool
1516
}{
1617
{
1718
name: "empty string",
@@ -43,12 +44,26 @@ func TestSplitNamespaces(t *testing.T) {
4344
input: ",,",
4445
want: nil,
4546
},
47+
{
48+
name: "invalid namespace uppercase",
49+
input: "Default",
50+
wantErr: true,
51+
},
52+
{
53+
name: "invalid namespace special chars",
54+
input: "ns;echo pwned",
55+
wantErr: true,
56+
},
4657
}
4758

4859
for _, tt := range tests {
4960
t.Run(tt.name, func(t *testing.T) {
50-
got := splitNamespaces(tt.input)
51-
if !reflect.DeepEqual(got, tt.want) {
61+
got, err := splitNamespaces(tt.input)
62+
if (err != nil) != tt.wantErr {
63+
t.Errorf("splitNamespaces(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
64+
return
65+
}
66+
if !tt.wantErr && !reflect.DeepEqual(got, tt.want) {
5267
t.Errorf("splitNamespaces(%q) = %v, want %v", tt.input, got, tt.want)
5368
}
5469
})

pkg/scan/gadget.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,19 @@ func (r *GadgetRunner) Run() (*GadgetResult, error) {
145145

146146
// RunWithContext executes kubectl gadget with context support for cancellation.
147147
func (r *GadgetRunner) RunWithContext(ctx context.Context) (*GadgetResult, error) {
148+
// Validate namespace names early
149+
if r.Namespaces != "" {
150+
for _, ns := range strings.Split(r.Namespaces, ",") {
151+
ns = strings.TrimSpace(ns)
152+
if ns == "" {
153+
continue
154+
}
155+
if err := util.ValidateNamespaceName(ns); err != nil {
156+
return nil, fmt.Errorf("invalid namespace: %w", err)
157+
}
158+
}
159+
}
160+
148161
if err := r.checkInstalled(ctx); err != nil {
149162
return nil, err
150163
}

pkg/util/validate.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package util
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
)
7+
8+
// RFC 1123 DNS subdomain: lowercase alphanumeric, may contain hyphens,
9+
// must start and end with alphanumeric, max 63 chars
10+
var namespaceRegex = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)
11+
12+
// ValidateNamespaceName checks if a namespace name is valid per RFC 1123.
13+
func ValidateNamespaceName(name string) error {
14+
if len(name) == 0 {
15+
return fmt.Errorf("namespace name cannot be empty")
16+
}
17+
if len(name) > 63 {
18+
return fmt.Errorf("namespace name %q exceeds 63 characters", name)
19+
}
20+
if !namespaceRegex.MatchString(name) {
21+
return fmt.Errorf("namespace name %q is invalid: must be lowercase alphanumeric with optional hyphens, starting and ending with alphanumeric", name)
22+
}
23+
return nil
24+
}

pkg/util/validate_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package util
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestValidateNamespaceName(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
input string
12+
wantErr bool
13+
}{
14+
{"valid simple", "default", false},
15+
{"valid with hyphen", "kube-system", false},
16+
{"valid with numbers", "app123", false},
17+
{"valid single char", "a", false},
18+
{"valid starts with number", "123app", false},
19+
{"empty", "", true},
20+
{"starts with hyphen", "-invalid", true},
21+
{"ends with hyphen", "invalid-", true},
22+
{"uppercase", "Default", true},
23+
{"spaces", "my namespace", true},
24+
{"special chars underscore", "my_namespace", true},
25+
{"special chars dot", "my.namespace", true},
26+
{"special chars semicolon", "ns;echo", true},
27+
{"too long", strings.Repeat("a", 64), true},
28+
{"max length valid", strings.Repeat("a", 63), false},
29+
}
30+
31+
for _, tt := range tests {
32+
t.Run(tt.name, func(t *testing.T) {
33+
err := ValidateNamespaceName(tt.input)
34+
if (err != nil) != tt.wantErr {
35+
t.Errorf("ValidateNamespaceName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
36+
}
37+
})
38+
}
39+
}

pkg/verify/comparator.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,16 @@ func (c *Comparator) findMatchingRule(details string) string {
125125
// ruleMatchesConnection checks if a rule could have produced the given connection
126126
func (c *Comparator) ruleMatchesConnection(rule policy.Rule, details string) bool {
127127
// Parse selectors from the rule
128-
fromSelector, _ := policy.ParseSelector(rule.From)
129-
toSelector, _ := policy.ParseSelector(rule.To)
128+
fromSelector, fromErr := policy.ParseSelector(rule.From)
129+
toSelector, toErr := policy.ParseSelector(rule.To)
130+
131+
// If selectors can't be parsed, we can't match this rule
132+
if rule.From != "" && fromErr != nil {
133+
return false
134+
}
135+
if rule.To != "" && toErr != nil {
136+
return false
137+
}
130138

131139
// Check if the "from" selector matches the source in details
132140
if fromSelector != nil {

pkg/verify/nca.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,14 @@ func (r *NCARunner) exportClusterState(ctx context.Context) (string, error) {
251251
return "", fmt.Errorf("failed to create temp directory: %w", err)
252252
}
253253

254+
// Cleanup on failure; caller is responsible for cleanup on success
255+
success := false
256+
defer func() {
257+
if !success {
258+
os.RemoveAll(clusterDir) // Best-effort cleanup
259+
}
260+
}()
261+
254262
resources := []string{"pods", "namespaces", "services", "networkpolicies"}
255263
kubectlArgs := []string{"get", strings.Join(resources, ","), "-A", "-o", "yaml"}
256264

@@ -261,16 +269,15 @@ func (r *NCARunner) exportClusterState(ctx context.Context) (string, error) {
261269

262270
result, err := util.RunCommandWithRetry(ctx, "kubectl", kubectlArgs, opts, util.DefaultRetryConfig())
263271
if err != nil {
264-
os.RemoveAll(clusterDir)
265272
return "", fmt.Errorf("kubectl get failed: %w", err)
266273
}
267274

268275
outPath := filepath.Join(clusterDir, "cluster-state.yaml")
269276
if err := os.WriteFile(outPath, []byte(result.Stdout), 0600); err != nil {
270-
os.RemoveAll(clusterDir)
271277
return "", fmt.Errorf("failed to write cluster state: %w", err)
272278
}
273279

280+
success = true
274281
return clusterDir, nil
275282
}
276283

0 commit comments

Comments
 (0)