diff --git a/cmd/plugin.go b/cmd/plugin.go new file mode 100644 index 000000000..25b648866 --- /dev/null +++ b/cmd/plugin.go @@ -0,0 +1,18 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +func newPluginCmd() *cobra.Command { + pluginCmd := &cobra.Command{ + Use: "plugin", + Short: "Manage and validate Kong plugins", + Long: `The plugin command set allows you to manage, validate, and lint Kong plugins locally.`, + } + + pluginCmd.AddCommand(newPluginLintCmd()) + + + return pluginCmd +} diff --git a/cmd/plugin_lint.go b/cmd/plugin_lint.go new file mode 100644 index 000000000..061c93b72 --- /dev/null +++ b/cmd/plugin_lint.go @@ -0,0 +1,100 @@ +package cmd + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/kong/deck/plugin/lua" + "github.com/spf13/cobra" + +) + +var ( + pluginLintCode string + pluginLintEdition string + pluginLintSandbox string +) + +// Executes plugin lint command. +func executePluginLint(cmd *cobra.Command, args []string) error{ + var luaCode string + var err error + + if pluginLintCode == "-" { + luaCode, err = readFromStdin() + } else { + content, readErr := os.ReadFile(pluginLintCode) + if readErr != nil { + return fmt.Errorf("failed to read file %s: %w", pluginLintCode, readErr) + } + luaCode = string(content) + + } + + if err != nil { + return fmt.Errorf("failed to read input: %w", err) + } + + if strings.TrimSpace(luaCode) == "" { + return errors.New("no Lua code provided. Use --code or pipe code to stdin") + } + + + v, err := lua.NewValidator(pluginLintEdition, "") + if err != nil { + return err + } + + violations, err := v.Validate(luaCode, pluginLintSandbox) + if err != nil { + return err + } + + if len(violations) == 0 { + fmt.Println("Success: No violations found. Your Lua code is safe for the specified sandbox.") + return nil + } + + fmt.Printf("Found %d violations:\n", len(violations)) + for _, vio := range violations { + lineInfo := "" + if vio.Line > 0 { + lineInfo = fmt.Sprintf(" (line %d)", vio.Line) + } + fmt.Printf(" - [%s] %s: %s%s\n", vio.Severity, vio.ID, vio.Message, lineInfo) + } + + + return errors.New("lua validation failed") + + + +} +func readFromStdin() (string, error) { + data, err := io.ReadAll(os.Stdin) + if err != nil { + return "", fmt.Errorf("error reading from stdin: %w", err) + } + + return string(data), err +} + + +func newPluginLintCmd() *cobra.Command { + pluginLintCmd := &cobra.Command{ + Use: "lint [flags]", + Short: "Check custom LUA code for security and sandbox compatibility", + RunE: func(cmd *cobra.Command, args []string) error { + return executePluginLint(cmd, args) + }, + } + + pluginLintCmd.Flags().StringVarP(&pluginLintCode, "code", "c", "-", "custom LUA code to validate. Use - to read from stdin.") + pluginLintCmd.Flags().StringVarP(&pluginLintEdition, "edition", "e", "ee", "Kong Edition [choices: ee (enterprise), oss (open source).") + pluginLintCmd.Flags().StringVarP(&pluginLintSandbox, "sandbox", "s", "strict", "Kong Edition [choices: ee (enterprise), oss (open source).") + + return pluginLintCmd +} diff --git a/cmd/root.go b/cmd/root.go index 05fbd2fbd..f1e1b2773 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -237,6 +237,9 @@ It can be used to export, import, or sync entities to Kong.`, rootCmd.AddCommand(newDiffCmd(true)) // deprecated, to exist under the `gateway` subcommand only rootCmd.AddCommand(newConvertCmd(true)) // deprecated, to exist under the `file` subcommand only rootCmd.AddCommand(newKonnectCmd()) // deprecated, to be removed + + rootCmd.AddCommand(newPluginCmd()) + { gatewayCmd := newGatewaySubCmd() rootCmd.AddCommand(gatewayCmd) @@ -267,6 +270,8 @@ It can be used to export, import, or sync entities to Kong.`, fileCmd.AddCommand(newKong2KicCmd()) fileCmd.AddCommand(newKong2TfCmd()) } + + return rootCmd } diff --git a/go.mod b/go.mod index 85be498d5..0fe8a8b4c 100644 --- a/go.mod +++ b/go.mod @@ -111,6 +111,7 @@ require ( github.com/xlab/treeprint v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.8.2 // indirect + github.com/yuin/gopher-lua v1.1.2 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect go.opentelemetry.io/otel v1.33.0 // indirect diff --git a/go.sum b/go.sum index 034e819c1..2459da319 100644 --- a/go.sum +++ b/go.sum @@ -415,6 +415,8 @@ github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= +github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= diff --git a/pkg/lua/policies/kong_ee_3x.yaml b/pkg/lua/policies/kong_ee_3x.yaml new file mode 100644 index 000000000..c66e1b925 --- /dev/null +++ b/pkg/lua/policies/kong_ee_3x.yaml @@ -0,0 +1,104 @@ +# Kong Enterprise Lua Sandbox Knowledge Base +# Accurate mapping from kong/tools/sandbox/environment/ + +version: "1.0" +edition: "EE" + +profiles: + - name: lua + description: "Base Lua environment. Standard libraries only." + allowed_globals: + - _VERSION + - assert + - error + - ipairs + - next + - pairs + - pcall + - print + - select + - tonumber + - tostring + - type + - unpack + - xpcall + allowed_prefixes: + - bit. + - coroutine. + - io.type + - jit. + - math. + - os.clock + - os.date + - os.difftime + - os.time + - string. + - table. + + - name: strict + extends: lua + description: "Standard Kong Sandbox. Safe PDK and Nginx utilities." + allowed_globals: + - kong + - ngx + allowed_prefixes: + - kong.client. + - kong.cluster. + - kong.default_workspace + - kong.ip. + - kong.jwe. + - kong.nginx. + - kong.node. + - kong.plugin. + - kong.request. + - kong.response. + - kong.service.request. + - kong.service.response. + - kong.table. + - kong.telemetry. + - kong.tracing. + - kong.version + - ngx.log + - ngx.sleep + - ngx.time + - ngx.re. + - ngx.req. + - ngx.resp. + - ngx.worker. + - ngx.config. + # Common constants + - ngx.HTTP_ + - ngx.OK + - ngx.ERR + - ngx.INFO + + - name: lax + extends: strict + description: "Permissive Sandbox. Allows DB, cache, and network sockets." + allowed_prefixes: + - kong.cache. + - kong.db.consumers. + - kong.db.services. + - kong.db.routes. + - kong.db.upstreams. + - kong.db.targets. + - kong.db.plugins. + - kong.db.certificates. + - kong.db.snis. + - kong.db.ca_certificates. + - kong.dns. + - kong.vault. + - ngx.socket. + - ngx.thread. + - ngx.location.capture + + +rules: + - id: LUA-EV-001 + pattern: "(_G\\[|getfenv|setfenv)" + severity: CRITICAL + message: "Potential sandbox evasion: global environment manipulation." + - id: LUA-SEC-001 + pattern: "require\\s*\\(['\"] (ffi|os|io) ['\"]\\)" + severity: CRITICAL + message: "Forbidden module loading." diff --git a/pkg/lua/policies/kong_oss_3x.yaml b/pkg/lua/policies/kong_oss_3x.yaml new file mode 100644 index 000000000..2d1bdbe2d --- /dev/null +++ b/pkg/lua/policies/kong_oss_3x.yaml @@ -0,0 +1,54 @@ +# Kong OSS Lua Sandbox Knowledge Base + +version: "1.0" +edition: "OSS" + +profiles: + - name: lua + description: "Base Lua environment for OSS" + allowed_globals: + - _VERSION + - assert + - error + - ipairs + - next + - pairs + - pcall + - print + - select + - tonumber + - tostring + - type + - unpack + - xpcall + allowed_prefixes: + - math. + - string. + - table. + + - name: standard + extends: lua + description: "Standard OSS Sandbox. Core PDK only." + allowed_globals: + - kong + - ngx + allowed_prefixes: + - kong.log. + - kong.request. + - kong.response. + - kong.service. + - kong.ip. + - kong.table. + - ngx.log + - ngx.sleep + - ngx.time + - ngx.re. + - ngx.exit + - ngx.HTTP_ + - ngx.OK + +rules: + - id: LUA-EV-001 + pattern: "(_G\\[|getfenv|setfenv)" + severity: CRITICAL + message: "Sandbox evasion: global environment access is strictly blocked." diff --git a/plugin/lua/policies/kong_ee_3x.yaml b/plugin/lua/policies/kong_ee_3x.yaml new file mode 100644 index 000000000..807fa95e5 --- /dev/null +++ b/plugin/lua/policies/kong_ee_3x.yaml @@ -0,0 +1,109 @@ +# Kong Enterprise Lua Sandbox Knowledge Base +# Accurate mapping from kong/tools/sandbox/environment/ + +version: "1.0" +edition: "EE" + +profiles: + - name: lua + description: "Base Lua environment. Standard libraries only." + allowed_globals: + - _VERSION + - assert + - error + - ipairs + - next + - pairs + - pcall + - print + - select + - tonumber + - tostring + - type + - unpack + - xpcall + allowed_prefixes: + - bit. + - coroutine. + - io.type + - jit. + - math. + - os.clock + - os.date + - os.difftime + - os.time + - string. + - table. + + - name: strict + extends: lua + description: "Standard Kong Sandbox. Safe PDK and Nginx utilities." + allowed_globals: + - kong + - ngx + allowed_prefixes: + - kong.client. + - kong.cluster. + - kong.default_workspace + - kong.ip. + - kong.jwe. + - kong.nginx. + - kong.node. + - kong.plugin. + - kong.request. + - kong.response. + - kong.service.request. + - kong.service.response. + - kong.table. + - kong.telemetry. + - kong.tracing. + - kong.version + - ngx.log + - ngx.sleep + - ngx.time + - ngx.re. + - ngx.req. + - ngx.resp. + - ngx.worker. + - ngx.config. + # Common constants + - ngx.HTTP_ + - ngx.OK + - ngx.ERR + - ngx.INFO + + - name: lax + extends: strict + description: "Permissive Sandbox. Advanced PDK capabilities." + allowed_prefixes: + - kong.cache. + - kong.db.consumers.select # Permesso solo select su tabelle specifiche + - kong.db.services.select + - kong.db.routes.select + - kong.db.upstreams.select + - kong.db.targets.select + - kong.db.plugins.select + - kong.dns. + - kong.vault. + - ngx.socket. + - ngx.thread. + + +rules: + - id: LUA-EV-001 + name: "Global Environment Access" + pattern: "(_G\\[|getfenv|setfenv)" + severity: CRITICAL + message: "Potential sandbox evasion: global environment manipulation." + + - id: LUA-SEC-001 + name: "Forbidden Module Loading" + pattern: "require\\s*\\(['\"] (ffi|os|io) ['\"]\\)" + severity: CRITICAL + message: "Forbidden module loading." + + - id: LUA-COMP-001 + name: "Restricted OS/IO calls" + pattern: "(os\\.execute|os\\.getenv|io\\.open|io\\.popen)" + severity: ERROR + message: "Direct OS or IO system calls are strictly forbidden for security reasons." diff --git a/plugin/lua/policies/kong_oss_3x.yaml b/plugin/lua/policies/kong_oss_3x.yaml new file mode 100644 index 000000000..27411a2b6 --- /dev/null +++ b/plugin/lua/policies/kong_oss_3x.yaml @@ -0,0 +1,60 @@ +# Kong OSS Lua Sandbox Knowledge Base +# Based on standard Kong OSS sandbox restrictions + +version: "1.0" +edition: "OSS" + +profiles: + - name: lua + description: "Base Lua environment for OSS" + allowed_globals: + - _VERSION + - assert + - error + - ipairs + - next + - pairs + - pcall + - print + - select + - tonumber + - tostring + - type + - unpack + - xpcall + allowed_prefixes: + - math. + - string. + - table. + + - name: standard + extends: lua + description: "Standard OSS Sandbox. Core PDK only." + allowed_globals: + - kong + - ngx + allowed_prefixes: + - kong.log. + - kong.request. + - kong.response. + - kong.service. + - kong.ip. + - kong.table. + - ngx.log + - ngx.sleep + - ngx.time + - ngx.re. + - ngx.exit + - ngx.HTTP_ + - ngx.OK + +rules: + - id: LUA-EV-001 + pattern: "(_G\\[|getfenv|setfenv)" + severity: CRITICAL + message: "Sandbox evasion: global environment access is strictly blocked." + + - id: LUA-COMP-001 + pattern: "(os\\.|io\\.)" + severity: ERROR + message: "System libraries (os, io) are completely unavailable in OSS sandbox." diff --git a/plugin/lua/validator.go b/plugin/lua/validator.go new file mode 100644 index 000000000..0b557fc3d --- /dev/null +++ b/plugin/lua/validator.go @@ -0,0 +1,267 @@ +package lua + +import ( + "embed" + "fmt" + "regexp" + "strings" + + "github.com/yuin/gopher-lua/ast" + "github.com/yuin/gopher-lua/parse" + "gopkg.in/yaml.v3" +) + +//go:embed policies/*.yaml +var defaultPolicies embed.FS + +type Violation struct { + ID string `yaml:"id"` + Message string `yaml:"message"` + Severity string `yaml:"severity"` + Line int `yaml:"line"` // Added line number +} + +type Rule struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Pattern string `yaml:"pattern"` + Severity string `yaml:"severity"` + Message string `yaml:"message"` + compiledReg *regexp.Regexp +} + +type Profile struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Extends string `yaml:"extends"` + AllowedGlobals []string `yaml:"allowed_globals"` + AllowedPrefixes []string `yaml:"allowed_prefixes"` +} + +type PolicyStore struct { + Version string `yaml:"version"` + Edition string `yaml:"edition"` + Profiles []Profile `yaml:"profiles"` + Rules []Rule `yaml:"rules"` +} + +type Validator struct { + store *PolicyStore +} + +func NewValidator(edition string, externalPath string) (*Validator, error) { + var data []byte + var err error + + fileName := "" + switch edition { + case "oss": + fileName = "policies/kong_oss_3x.yaml" + case "ee": + fileName = "policies/kong_ee_3x.yaml" + default: + return nil, fmt.Errorf("unsupported Kong edition: %s", edition) + } + + data, err = defaultPolicies.ReadFile(fileName) + if err != nil { + return nil, fmt.Errorf("failed to read policy file %s: %w", fileName, err) + } + + store := &PolicyStore{} + if err := yaml.Unmarshal(data, store); err != nil { + return nil, fmt.Errorf("failed to parse policy YAML: %w", err) + } + + for i := range store.Rules { + re, err := regexp.Compile(store.Rules[i].Pattern) + if err != nil { + return nil, fmt.Errorf("invalid regex in rule %s: %w", store.Rules[i].ID, err) + } + store.Rules[i].compiledReg = re + } + + return &Validator{store: store}, nil +} + +// resolveProfile builds a complete profile by merging current with all ancestors. +func (v *Validator) resolveProfile(name string) (*Profile, error) { + var base *Profile + for i := range v.store.Profiles { + if v.store.Profiles[i].Name == name { + base = &v.store.Profiles[i] + break + } + } + if base == nil { + return nil, fmt.Errorf("profile %s not found", name) + } + + // Deep copy base data + res := &Profile{ + Name: base.Name, + AllowedGlobals: append([]string{}, base.AllowedGlobals...), + AllowedPrefixes: append([]string{}, base.AllowedPrefixes...), + } + + // Recursive merge of parents + if base.Extends != "" { + parent, err := v.resolveProfile(base.Extends) + if err != nil { + return nil, err + } + res.AllowedGlobals = append(res.AllowedGlobals, parent.AllowedGlobals...) + res.AllowedPrefixes = append(res.AllowedPrefixes, parent.AllowedPrefixes...) + } + return res, nil +} + +func (v *Validator) Validate(code string, profileName string) ([]Violation, error) { + profile, err := v.resolveProfile(profileName) + if err != nil { + return nil, err + } + + violations := []Violation{} + + // Phase 1: Security Scan (Regex) + for _, rule := range v.store.Rules { + if rule.compiledReg.MatchString(code) { + violations = append(violations, Violation{ + ID: rule.ID, + Message: rule.Message, + Severity: rule.Severity, + Line: 0, // Regex doesn't provide line info easily + }) + } + } + + // Phase 2: Semantic Analysis (AST) + astViolations := v.validateAST(code, profile) + violations = append(violations, astViolations...) + + return violations, nil +} + +func (v *Validator) validateAST(code string, profile *Profile) []Violation { + violations := []Violation{} + stats, err := parse.Parse(strings.NewReader(code), "") + if err != nil { + violations = append(violations, Violation{ + ID: "LUA-SYNTAX", Message: fmt.Sprintf("Lua syntax error: %v", err), Severity: "ERROR", + }) + return violations + } + + inspector := func(expr ast.Expr) { + if call, ok := expr.(*ast.FuncCallExpr); ok { + var funcName string + if call.Receiver != nil && call.Method != "" { + funcName = v.extractFullName(call.Receiver) + ":" + call.Method + } else { + funcName = v.extractFullName(call.Func) + } + + if funcName != "" && !v.isAllowed(funcName, profile) { + violations = append(violations, Violation{ + ID: "LUA-WHITELIST", + Message: fmt.Sprintf("Forbidden call detected: '%s' is not allowed in %s profile", funcName, profile.Name), + Severity: "ERROR", + Line: call.Line(), // Extract line from AST + }) + } + } + } + + for _, stmt := range stats { + v.walkStatement(stmt, inspector) + } + return violations +} + +func (v *Validator) walkStatement(stmt ast.Stmt, inspect func(ast.Expr)) { + if stmt == nil { + return + } + switch s := stmt.(type) { + case *ast.FuncCallStmt: + v.walkExpr(s.Expr, inspect) + case *ast.AssignStmt: + for _, expr := range s.Rhs { + v.walkExpr(expr, inspect) + } + case *ast.LocalAssignStmt: + for _, expr := range s.Exprs { + v.walkExpr(expr, inspect) + } + case *ast.IfStmt: + v.walkExpr(s.Condition, inspect) + for _, sub := range s.Then { + v.walkStatement(sub, inspect) + } + for _, sub := range s.Else { + v.walkStatement(sub, inspect) + } + } +} + +func (v *Validator) walkExpr(expr ast.Expr, inspect func(ast.Expr)) { + if expr == nil { + return + } + inspect(expr) + + switch e := expr.(type) { + case *ast.FuncCallExpr: + for _, arg := range e.Args { + v.walkExpr(arg, inspect) + } + case *ast.AttrGetExpr: // Handle recursive index access: obj[1].prop + v.walkExpr(e.Object, inspect) + v.walkExpr(e.Key, inspect) + case *ast.LogicalOpExpr: + v.walkExpr(e.Lhs, inspect) + v.walkExpr(e.Rhs, inspect) + } +} + +func (v *Validator) extractFullName(expr ast.Expr) string { + if expr == nil { + return "" + } + switch e := expr.(type) { + case *ast.IdentExpr: + return e.Value + case *ast.StringExpr: + return e.Value + case *ast.AttrGetExpr: + left := v.extractFullName(e.Object) + if left == "" { + return "" + } + right := v.extractFullName(e.Key) + if right == "" { + return left + ".*" + } + return left + "." + right + } + return "" +} + +func (v *Validator) isAllowed(name string, profile *Profile) bool { + for _, g := range profile.AllowedGlobals { + if name == g { + return true + } + } + for _, p := range profile.AllowedPrefixes { + if strings.HasPrefix(name, p) { + return true + } + } + return false +} + +func (v *Validator) GetEdition() string { + return v.store.Edition +} diff --git a/plugin/lua/validator_test.go b/plugin/lua/validator_test.go new file mode 100644 index 000000000..158e418ee --- /dev/null +++ b/plugin/lua/validator_test.go @@ -0,0 +1,98 @@ +package lua_test + +import ( + "testing" + + "github.com/kong/deck/plugin/lua" +) + +func TestMultiEditionValidator(t *testing.T) { + // Test OSS Edition + t.Run("Kong OSS Validation", func(t *testing.T) { + v, err := lua.NewValidator("oss", "") + if err != nil { + t.Fatalf("Failed to initialize OSS validator: %v", err) + } + if v.GetEdition() != "OSS" { + t.Errorf("Expected edition OSS, got %s", v.GetEdition()) + } + + // OSS should block all 'os.' calls including execute + code := `os.execute("ls")` + violations, _ := v.Validate(code, "standard") + if len(violations) == 0 { + t.Error("Expected violation for 'os.execute' in OSS, but got none") + } + }) + + // Test EE Edition + t.Run("Kong EE Validation", func(t *testing.T) { + v, err := lua.NewValidator("ee", "") + if err != nil { + t.Fatalf("Failed to initialize EE validator: %v", err) + } + + // EE should detect evasion via _G + code := `local x = _G["require"]` + violations, _ := v.Validate(code, "strict") + found := false + for _, v := range violations { + if v.ID == "LUA-EV-001" { + found = true + break + } + } + if !found { + t.Error("Expected LUA-EV-001 violation in EE, but got none") + } + }) +} + + +func TestInsidiousCases(t *testing.T) { + v, err := lua.NewValidator("ee", "") + if err != nil { + t.Fatalf("Failed to initialize validator: %v", err) + } + + tests := []struct { + name string + code string + profile string + }{ + { + name: "Whitespace and comments between identifiers", + code: `kong . -- sneaky comment + log . + err("test")`, + profile: "lua", // Should fail because 'kong' is not in lua profile + }, + { + name: "Deeply nested forbidden call", + code: `if true then + for i=1,10 do + if i == 5 then os.execute("ls") end + end + end`, + profile: "lax", // os.execute is forbidden even in lax + }, + { + name: "Forbidden call as function argument", + code: `print(os.execute("ls"))`, + profile: "strict", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + violations, err := v.Validate(tt.code, tt.profile) + if err != nil { + t.Fatalf("Engine error: %v", err) + } + + if len(violations) == 0 { + t.Errorf("Case '%s' failed: expected violations but got none", tt.name) + } + }) + } +}