- Deny-by-default filesystem isolation for Linux (Landlock) and macOS (Seatbelt) - Prevent learning mode from collapsing read paths to $HOME - Add Linux deny-by-default lessons to experience docs
697 lines
16 KiB
Go
697 lines
16 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestConfigValidate(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
config Config
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "valid empty config",
|
|
config: Config{},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid config with proxy",
|
|
config: Config{
|
|
Network: NetworkConfig{
|
|
ProxyURL: "socks5://localhost:1080",
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid socks5h proxy",
|
|
config: Config{
|
|
Network: NetworkConfig{
|
|
ProxyURL: "socks5h://proxy.example.com:1080",
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "invalid proxy - wrong scheme",
|
|
config: Config{
|
|
Network: NetworkConfig{
|
|
ProxyURL: "http://localhost:1080",
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid proxy - no port",
|
|
config: Config{
|
|
Network: NetworkConfig{
|
|
ProxyURL: "socks5://localhost",
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid proxy - no host",
|
|
config: Config{
|
|
Network: NetworkConfig{
|
|
ProxyURL: "socks5://:1080",
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid proxy - not a URL",
|
|
config: Config{
|
|
Network: NetworkConfig{
|
|
ProxyURL: "not-a-url",
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "empty allowRead path",
|
|
config: Config{
|
|
Filesystem: FilesystemConfig{
|
|
AllowRead: []string{""},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "empty denyRead path",
|
|
config: Config{
|
|
Filesystem: FilesystemConfig{
|
|
DenyRead: []string{""},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "empty allowWrite path",
|
|
config: Config{
|
|
Filesystem: FilesystemConfig{
|
|
AllowWrite: []string{""},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "empty denyWrite path",
|
|
config: Config{
|
|
Filesystem: FilesystemConfig{
|
|
DenyWrite: []string{""},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := tt.config.Validate()
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("Config.Validate() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDefault(t *testing.T) {
|
|
cfg := Default()
|
|
if cfg == nil {
|
|
t.Fatal("Default() returned nil")
|
|
}
|
|
if cfg.Network.ProxyURL != "" {
|
|
t.Error("ProxyURL should be empty by default")
|
|
}
|
|
if cfg.Filesystem.DenyRead == nil {
|
|
t.Error("DenyRead should not be nil")
|
|
}
|
|
if cfg.Filesystem.AllowWrite == nil {
|
|
t.Error("AllowWrite should not be nil")
|
|
}
|
|
if cfg.Filesystem.DenyWrite == nil {
|
|
t.Error("DenyWrite should not be nil")
|
|
}
|
|
}
|
|
|
|
func TestLoad(t *testing.T) {
|
|
// Create temp directory for test files
|
|
tmpDir := t.TempDir()
|
|
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
setup func(string) string // returns path
|
|
wantNil bool
|
|
wantErr bool
|
|
checkConfig func(*testing.T, *Config)
|
|
}{
|
|
{
|
|
name: "nonexistent file",
|
|
setup: func(dir string) string { return filepath.Join(dir, "nonexistent.json") },
|
|
wantNil: true,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "empty file",
|
|
content: "",
|
|
setup: func(dir string) string {
|
|
path := filepath.Join(dir, "empty.json")
|
|
_ = os.WriteFile(path, []byte(""), 0o600)
|
|
return path
|
|
},
|
|
wantNil: true,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "whitespace only file",
|
|
content: " \n\t ",
|
|
setup: func(dir string) string {
|
|
path := filepath.Join(dir, "whitespace.json")
|
|
_ = os.WriteFile(path, []byte(" \n\t "), 0o600)
|
|
return path
|
|
},
|
|
wantNil: true,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid config with proxy",
|
|
setup: func(dir string) string {
|
|
path := filepath.Join(dir, "valid.json")
|
|
content := `{"network":{"proxyUrl":"socks5://localhost:1080"}}`
|
|
_ = os.WriteFile(path, []byte(content), 0o600)
|
|
return path
|
|
},
|
|
wantNil: false,
|
|
wantErr: false,
|
|
checkConfig: func(t *testing.T, cfg *Config) {
|
|
if cfg.Network.ProxyURL != "socks5://localhost:1080" {
|
|
t.Errorf("expected socks5://localhost:1080, got %s", cfg.Network.ProxyURL)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "invalid JSON",
|
|
setup: func(dir string) string {
|
|
path := filepath.Join(dir, "invalid.json")
|
|
_ = os.WriteFile(path, []byte("{invalid json}"), 0o600)
|
|
return path
|
|
},
|
|
wantNil: false,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid proxy URL in config",
|
|
setup: func(dir string) string {
|
|
path := filepath.Join(dir, "invalid_proxy.json")
|
|
content := `{"network":{"proxyUrl":"http://localhost:1080"}}`
|
|
_ = os.WriteFile(path, []byte(content), 0o600)
|
|
return path
|
|
},
|
|
wantNil: false,
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
path := tt.setup(tmpDir)
|
|
cfg, err := Load(path)
|
|
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("Load() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
|
|
if tt.wantNil && cfg != nil {
|
|
t.Error("Load() expected nil config")
|
|
return
|
|
}
|
|
|
|
if !tt.wantNil && !tt.wantErr && cfg == nil {
|
|
t.Error("Load() returned nil config unexpectedly")
|
|
return
|
|
}
|
|
|
|
if tt.checkConfig != nil && cfg != nil {
|
|
tt.checkConfig(t, cfg)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDefaultConfigPath(t *testing.T) {
|
|
path := DefaultConfigPath()
|
|
if path == "" {
|
|
t.Error("DefaultConfigPath() returned empty string")
|
|
}
|
|
// Should end with greywall.json (either new XDG path or legacy .greywall.json)
|
|
base := filepath.Base(path)
|
|
if base != "greywall.json" && base != ".greywall.json" {
|
|
t.Errorf("DefaultConfigPath() = %q, expected to end with greywall.json or .greywall.json", path)
|
|
}
|
|
}
|
|
|
|
func TestMerge(t *testing.T) {
|
|
t.Run("nil base", func(t *testing.T) {
|
|
override := &Config{
|
|
AllowPty: true,
|
|
Network: NetworkConfig{
|
|
ProxyURL: "socks5://localhost:1080",
|
|
},
|
|
}
|
|
result := Merge(nil, override)
|
|
if !result.AllowPty {
|
|
t.Error("expected AllowPty to be true")
|
|
}
|
|
if result.Network.ProxyURL != "socks5://localhost:1080" {
|
|
t.Error("expected ProxyURL to be socks5://localhost:1080")
|
|
}
|
|
if result.Extends != "" {
|
|
t.Error("expected Extends to be cleared")
|
|
}
|
|
})
|
|
|
|
t.Run("nil override", func(t *testing.T) {
|
|
base := &Config{
|
|
AllowPty: true,
|
|
Network: NetworkConfig{
|
|
ProxyURL: "socks5://localhost:1080",
|
|
},
|
|
}
|
|
result := Merge(base, nil)
|
|
if !result.AllowPty {
|
|
t.Error("expected AllowPty to be true")
|
|
}
|
|
if result.Network.ProxyURL != "socks5://localhost:1080" {
|
|
t.Error("expected ProxyURL to be preserved")
|
|
}
|
|
})
|
|
|
|
t.Run("both nil", func(t *testing.T) {
|
|
result := Merge(nil, nil)
|
|
if result == nil {
|
|
t.Fatal("expected non-nil result")
|
|
}
|
|
})
|
|
|
|
t.Run("proxy URL override wins", func(t *testing.T) {
|
|
base := &Config{
|
|
Network: NetworkConfig{
|
|
ProxyURL: "socks5://base:1080",
|
|
},
|
|
}
|
|
override := &Config{
|
|
Network: NetworkConfig{
|
|
ProxyURL: "socks5://override:1080",
|
|
},
|
|
}
|
|
result := Merge(base, override)
|
|
|
|
if result.Network.ProxyURL != "socks5://override:1080" {
|
|
t.Errorf("expected override ProxyURL, got %s", result.Network.ProxyURL)
|
|
}
|
|
})
|
|
|
|
t.Run("proxy URL base preserved when override empty", func(t *testing.T) {
|
|
base := &Config{
|
|
Network: NetworkConfig{
|
|
ProxyURL: "socks5://base:1080",
|
|
},
|
|
}
|
|
override := &Config{
|
|
Network: NetworkConfig{},
|
|
}
|
|
result := Merge(base, override)
|
|
|
|
if result.Network.ProxyURL != "socks5://base:1080" {
|
|
t.Errorf("expected base ProxyURL, got %s", result.Network.ProxyURL)
|
|
}
|
|
})
|
|
|
|
t.Run("merge boolean flags", func(t *testing.T) {
|
|
base := &Config{
|
|
AllowPty: false,
|
|
Network: NetworkConfig{
|
|
AllowLocalBinding: true,
|
|
},
|
|
}
|
|
override := &Config{
|
|
AllowPty: true,
|
|
Network: NetworkConfig{
|
|
AllowLocalOutbound: boolPtr(true),
|
|
},
|
|
}
|
|
result := Merge(base, override)
|
|
|
|
if !result.AllowPty {
|
|
t.Error("expected AllowPty to be true (from override)")
|
|
}
|
|
if !result.Network.AllowLocalBinding {
|
|
t.Error("expected AllowLocalBinding to be true (from base)")
|
|
}
|
|
if result.Network.AllowLocalOutbound == nil || !*result.Network.AllowLocalOutbound {
|
|
t.Error("expected AllowLocalOutbound to be true (from override)")
|
|
}
|
|
})
|
|
|
|
t.Run("merge command config", func(t *testing.T) {
|
|
base := &Config{
|
|
Command: CommandConfig{
|
|
Deny: []string{"git push", "rm -rf"},
|
|
},
|
|
}
|
|
override := &Config{
|
|
Command: CommandConfig{
|
|
Deny: []string{"sudo"},
|
|
Allow: []string{"git status"},
|
|
},
|
|
}
|
|
result := Merge(base, override)
|
|
|
|
if len(result.Command.Deny) != 3 {
|
|
t.Errorf("expected 3 denied commands, got %d", len(result.Command.Deny))
|
|
}
|
|
if len(result.Command.Allow) != 1 {
|
|
t.Errorf("expected 1 allowed command, got %d", len(result.Command.Allow))
|
|
}
|
|
})
|
|
|
|
t.Run("merge filesystem config", func(t *testing.T) {
|
|
base := &Config{
|
|
Filesystem: FilesystemConfig{
|
|
AllowWrite: []string{"."},
|
|
DenyRead: []string{"~/.ssh/**"},
|
|
},
|
|
}
|
|
override := &Config{
|
|
Filesystem: FilesystemConfig{
|
|
AllowWrite: []string{"/tmp"},
|
|
DenyWrite: []string{".env"},
|
|
},
|
|
}
|
|
result := Merge(base, override)
|
|
|
|
if len(result.Filesystem.AllowWrite) != 2 {
|
|
t.Errorf("expected 2 write paths, got %d", len(result.Filesystem.AllowWrite))
|
|
}
|
|
if len(result.Filesystem.DenyRead) != 1 {
|
|
t.Errorf("expected 1 deny read path, got %d", len(result.Filesystem.DenyRead))
|
|
}
|
|
if len(result.Filesystem.DenyWrite) != 1 {
|
|
t.Errorf("expected 1 deny write path, got %d", len(result.Filesystem.DenyWrite))
|
|
}
|
|
})
|
|
|
|
t.Run("merge defaultDenyRead and allowRead", func(t *testing.T) {
|
|
base := &Config{
|
|
Filesystem: FilesystemConfig{
|
|
DefaultDenyRead: boolPtr(true),
|
|
AllowRead: []string{"/home/user/project"},
|
|
},
|
|
}
|
|
override := &Config{
|
|
Filesystem: FilesystemConfig{
|
|
AllowRead: []string{"/home/user/other"},
|
|
},
|
|
}
|
|
result := Merge(base, override)
|
|
|
|
if !result.Filesystem.IsDefaultDenyRead() {
|
|
t.Error("expected IsDefaultDenyRead() to be true (from base)")
|
|
}
|
|
if len(result.Filesystem.AllowRead) != 2 {
|
|
t.Errorf("expected 2 allowRead paths, got %d: %v", len(result.Filesystem.AllowRead), result.Filesystem.AllowRead)
|
|
}
|
|
})
|
|
|
|
t.Run("defaultDenyRead nil defaults to true", func(t *testing.T) {
|
|
base := &Config{
|
|
Filesystem: FilesystemConfig{},
|
|
}
|
|
result := Merge(base, nil)
|
|
if !result.Filesystem.IsDefaultDenyRead() {
|
|
t.Error("expected IsDefaultDenyRead() to be true when nil (deny-by-default)")
|
|
}
|
|
})
|
|
|
|
t.Run("defaultDenyRead explicit false overrides", func(t *testing.T) {
|
|
base := &Config{
|
|
Filesystem: FilesystemConfig{
|
|
DefaultDenyRead: boolPtr(true),
|
|
},
|
|
}
|
|
override := &Config{
|
|
Filesystem: FilesystemConfig{
|
|
DefaultDenyRead: boolPtr(false),
|
|
},
|
|
}
|
|
result := Merge(base, override)
|
|
if result.Filesystem.IsDefaultDenyRead() {
|
|
t.Error("expected IsDefaultDenyRead() to be false (override explicit false)")
|
|
}
|
|
})
|
|
}
|
|
|
|
func boolPtr(b bool) *bool {
|
|
return &b
|
|
}
|
|
|
|
func TestValidateHostPattern(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
pattern string
|
|
wantErr bool
|
|
}{
|
|
// Valid patterns
|
|
{"simple hostname", "server1", false},
|
|
{"domain", "example.com", false},
|
|
{"subdomain", "prod.example.com", false},
|
|
{"wildcard prefix", "*.example.com", false},
|
|
{"wildcard middle", "prod-*.example.com", false},
|
|
{"ip address", "192.168.1.1", false},
|
|
{"ipv6 address", "::1", false},
|
|
{"ipv6 full", "2001:db8::1", false},
|
|
{"localhost", "localhost", false},
|
|
|
|
// Invalid patterns
|
|
{"empty", "", true},
|
|
{"with protocol", "ssh://example.com", true},
|
|
{"with path", "example.com/path", true},
|
|
{"with port", "example.com:22", true},
|
|
{"with username", "user@example.com", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validateHostPattern(tt.pattern)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("validateHostPattern(%q) error = %v, wantErr %v", tt.pattern, err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMatchesHost(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
hostname string
|
|
pattern string
|
|
want bool
|
|
}{
|
|
// Exact matches
|
|
{"exact match", "server1.example.com", "server1.example.com", true},
|
|
{"exact match case insensitive", "Server1.Example.COM", "server1.example.com", true},
|
|
{"exact no match", "server2.example.com", "server1.example.com", false},
|
|
|
|
// Wildcard matches
|
|
{"wildcard prefix", "api.example.com", "*.example.com", true},
|
|
{"wildcard prefix deep", "deep.api.example.com", "*.example.com", true},
|
|
{"wildcard no match base", "example.com", "*.example.com", false},
|
|
{"wildcard middle", "prod-web-01.example.com", "prod-*.example.com", true},
|
|
{"wildcard middle no match", "dev-web-01.example.com", "prod-*.example.com", false},
|
|
{"wildcard suffix", "server1.prod", "server1.*", true},
|
|
{"multiple wildcards", "prod-web-01.us-east.example.com", "prod-*-*.example.com", true},
|
|
|
|
// Star matches all
|
|
{"star matches all", "anything.example.com", "*", true},
|
|
|
|
// IP addresses
|
|
{"ip exact match", "192.168.1.1", "192.168.1.1", true},
|
|
{"ip no match", "192.168.1.2", "192.168.1.1", false},
|
|
{"ip wildcard", "192.168.1.100", "192.168.1.*", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := MatchesHost(tt.hostname, tt.pattern)
|
|
if got != tt.want {
|
|
t.Errorf("MatchesHost(%q, %q) = %v, want %v", tt.hostname, tt.pattern, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSSHConfigValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
config Config
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "valid SSH config",
|
|
config: Config{
|
|
SSH: SSHConfig{
|
|
AllowedHosts: []string{"*.example.com", "prod-*.internal"},
|
|
AllowedCommands: []string{"ls", "cat", "grep"},
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "invalid allowed host with protocol",
|
|
config: Config{
|
|
SSH: SSHConfig{
|
|
AllowedHosts: []string{"ssh://example.com"},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid denied host with username",
|
|
config: Config{
|
|
SSH: SSHConfig{
|
|
DeniedHosts: []string{"user@example.com"},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "empty allowed command",
|
|
config: Config{
|
|
SSH: SSHConfig{
|
|
AllowedHosts: []string{"example.com"},
|
|
AllowedCommands: []string{"ls", ""},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "empty denied command",
|
|
config: Config{
|
|
SSH: SSHConfig{
|
|
AllowedHosts: []string{"example.com"},
|
|
DeniedCommands: []string{""},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := tt.config.Validate()
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("Config.Validate() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMergeSSHConfig(t *testing.T) {
|
|
t.Run("merge SSH allowed hosts", func(t *testing.T) {
|
|
base := &Config{
|
|
SSH: SSHConfig{
|
|
AllowedHosts: []string{"prod-*.example.com"},
|
|
},
|
|
}
|
|
override := &Config{
|
|
SSH: SSHConfig{
|
|
AllowedHosts: []string{"dev-*.example.com"},
|
|
},
|
|
}
|
|
result := Merge(base, override)
|
|
|
|
if len(result.SSH.AllowedHosts) != 2 {
|
|
t.Errorf("expected 2 allowed hosts, got %d: %v", len(result.SSH.AllowedHosts), result.SSH.AllowedHosts)
|
|
}
|
|
})
|
|
|
|
t.Run("merge SSH commands", func(t *testing.T) {
|
|
base := &Config{
|
|
SSH: SSHConfig{
|
|
AllowedCommands: []string{"ls", "cat"},
|
|
DeniedCommands: []string{"rm -rf"},
|
|
},
|
|
}
|
|
override := &Config{
|
|
SSH: SSHConfig{
|
|
AllowedCommands: []string{"grep", "find"},
|
|
DeniedCommands: []string{"shutdown"},
|
|
},
|
|
}
|
|
result := Merge(base, override)
|
|
|
|
if len(result.SSH.AllowedCommands) != 4 {
|
|
t.Errorf("expected 4 allowed commands, got %d", len(result.SSH.AllowedCommands))
|
|
}
|
|
if len(result.SSH.DeniedCommands) != 2 {
|
|
t.Errorf("expected 2 denied commands, got %d", len(result.SSH.DeniedCommands))
|
|
}
|
|
})
|
|
|
|
t.Run("merge SSH boolean flags", func(t *testing.T) {
|
|
base := &Config{
|
|
SSH: SSHConfig{
|
|
AllowAllCommands: false,
|
|
InheritDeny: true,
|
|
},
|
|
}
|
|
override := &Config{
|
|
SSH: SSHConfig{
|
|
AllowAllCommands: true,
|
|
InheritDeny: false,
|
|
},
|
|
}
|
|
result := Merge(base, override)
|
|
|
|
if !result.SSH.AllowAllCommands {
|
|
t.Error("expected AllowAllCommands to be true (OR logic)")
|
|
}
|
|
if !result.SSH.InheritDeny {
|
|
t.Error("expected InheritDeny to be true (OR logic)")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestValidateProxyURL(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
url string
|
|
wantErr bool
|
|
}{
|
|
{"valid socks5", "socks5://localhost:1080", false},
|
|
{"valid socks5h", "socks5h://proxy.example.com:1080", false},
|
|
{"valid socks5 with ip", "socks5://192.168.1.1:1080", false},
|
|
{"http scheme", "http://localhost:1080", true},
|
|
{"https scheme", "https://localhost:1080", true},
|
|
{"no port", "socks5://localhost", true},
|
|
{"no host", "socks5://:1080", true},
|
|
{"not a URL", "not-a-url", true},
|
|
{"empty", "", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validateProxyURL(tt.url)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("validateProxyURL(%q) error = %v, wantErr %v", tt.url, err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|