diff --git a/app/cli/cmd/root.go b/app/cli/cmd/root.go index 73bcc7d61..4800e2cb3 100644 --- a/app/cli/cmd/root.go +++ b/app/cli/cmd/root.go @@ -34,7 +34,7 @@ import ( "github.com/chainloop-dev/chainloop/app/cli/pkg/plugins" v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" "github.com/chainloop-dev/chainloop/pkg/grpcconn" - "github.com/chainloop-dev/chainloop/pkg/policies/engine/rego/builtins" + "github.com/chainloop-dev/chainloop/pkg/policies/engine/builtins" "github.com/rs/zerolog" "github.com/spf13/cobra" "github.com/spf13/viper" diff --git a/app/cli/internal/policydevel/eval.go b/app/cli/internal/policydevel/eval.go index 0531d42d8..83036f6ad 100644 --- a/app/cli/internal/policydevel/eval.go +++ b/app/cli/internal/policydevel/eval.go @@ -1,4 +1,4 @@ -// Copyright 2025 The Chainloop Authors. +// Copyright 2024-2025 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -18,7 +18,6 @@ import ( "context" "encoding/json" "fmt" - "os" controlplanev1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" @@ -203,8 +202,3 @@ func craft(materialPath string, kind v1.CraftingSchema_Material_MaterialType, na } return m, nil } - -func fileNotExists(path string) bool { - _, err := os.Stat(path) - return os.IsNotExist(err) -} diff --git a/app/cli/internal/policydevel/init.go b/app/cli/internal/policydevel/init.go index afa85dab5..ded515c37 100644 --- a/app/cli/internal/policydevel/init.go +++ b/app/cli/internal/policydevel/init.go @@ -1,5 +1,5 @@ // -// Copyright 2025 The Chainloop Authors. +// Copyright 2024-2025 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/app/cli/internal/policydevel/lint.go b/app/cli/internal/policydevel/lint.go index 87a5d1d59..9f6dffc6c 100644 --- a/app/cli/internal/policydevel/lint.go +++ b/app/cli/internal/policydevel/lint.go @@ -1,5 +1,5 @@ // -// Copyright 2025 The Chainloop Authors. +// Copyright 2024-2025 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -26,7 +26,9 @@ import ( v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/unmarshal" + "github.com/chainloop-dev/chainloop/pkg/policies/engine" "github.com/chainloop-dev/chainloop/pkg/resourceloader" + extism "github.com/extism/go-sdk" opaAst "github.com/open-policy-agent/opa/v1/ast" "github.com/open-policy-agent/opa/v1/format" "github.com/styrainc/regal/pkg/config" @@ -43,6 +45,7 @@ type PolicyToLint struct { Path string YAMLFiles []*File RegoFiles []*File + WASMFiles []*File Format bool Config string Errors []ValidationError @@ -108,21 +111,21 @@ func Lookup(absPath, config string, format bool) (*PolicyToLint, error) { return nil, err } - // Load referenced rego files from all YAML files - if err := policy.loadReferencedRegoFiles(filepath.Dir(resolvedPath)); err != nil { + // Load referenced policy files (rego or wasm) from all YAML files + if err := policy.loadReferencedPolicyFiles(filepath.Dir(resolvedPath)); err != nil { return nil, err } // Verify we found at least one valid file - if len(policy.YAMLFiles) == 0 && len(policy.RegoFiles) == 0 { - return nil, fmt.Errorf("no valid .yaml/.yml or .rego files found") + if len(policy.YAMLFiles) == 0 && len(policy.RegoFiles) == 0 && len(policy.WASMFiles) == 0 { + return nil, fmt.Errorf("no valid .yaml/.yml, .rego, or .wasm files found") } return policy, nil } -// Loads referenced rego files from YAML files in the policy -func (p *PolicyToLint) loadReferencedRegoFiles(baseDir string) error { +// Loads referenced policy files (rego or wasm) from YAML files in the policy +func (p *PolicyToLint) loadReferencedPolicyFiles(baseDir string) error { seen := make(map[string]struct{}) for _, yamlFile := range p.YAMLFiles { var parsed v1.Policy @@ -131,14 +134,14 @@ func (p *PolicyToLint) loadReferencedRegoFiles(baseDir string) error { continue } for _, spec := range parsed.Spec.Policies { - regoPath := spec.GetPath() - if regoPath != "" { + policyPath := spec.GetPath() + if policyPath != "" { // If path is relative, make it relative to the YAML file's directory - if !filepath.IsAbs(regoPath) { - regoPath = filepath.Join(baseDir, regoPath) + if !filepath.IsAbs(policyPath) { + policyPath = filepath.Join(baseDir, policyPath) } - resolvedPath, err := resourceloader.GetPathForResource(regoPath) + resolvedPath, err := resourceloader.GetPathForResource(policyPath) if err != nil { return err } @@ -156,13 +159,21 @@ func (p *PolicyToLint) loadReferencedRegoFiles(baseDir string) error { } func (p *PolicyToLint) processFile(filePath string) error { + ext := strings.ToLower(filepath.Ext(filePath)) + + // Read file content once content, err := os.ReadFile(filePath) if err != nil { return err } - ext := strings.ToLower(filepath.Ext(filePath)) switch ext { + case ".wasm": + // Verify magic bytes + if engine.DetectPolicyType(content) != engine.PolicyTypeWASM { + return fmt.Errorf("file has .wasm extension but is not a valid WASM file") + } + p.WASMFiles = append(p.WASMFiles, &File{Path: filePath, Content: content}) case ".yaml", ".yml": p.YAMLFiles = append(p.YAMLFiles, &File{ Path: filePath, @@ -174,7 +185,7 @@ func (p *PolicyToLint) processFile(filePath string) error { Content: content, }) default: - return fmt.Errorf("unsupported file extension %s, must be .yaml/.yml or .rego", ext) + return fmt.Errorf("unsupported file extension %s, must be .yaml/.yml, .rego, or .wasm", ext) } return nil @@ -185,6 +196,11 @@ func (p *PolicyToLint) Validate() { for _, regoFile := range p.RegoFiles { p.validateRegoFile(regoFile) } + + // Validate WASM files + for _, wasmFile := range p.WASMFiles { + p.validateWasmFile(wasmFile) + } } func (p *PolicyToLint) validateRegoFile(file *File) { @@ -200,6 +216,35 @@ func (p *PolicyToLint) validateRegoFile(file *File) { } } +// validateWasmFile validates a WASM policy file by checking that it exports the required Execute function +func (p *PolicyToLint) validateWasmFile(file *File) { + ctx := context.Background() + + // Create Extism manifest + manifest := extism.Manifest{ + Wasm: []extism.Wasm{ + extism.WasmData{Data: file.Content}, + }, + } + + cfg := extism.PluginConfig{ + EnableWasi: true, + } + + // Create plugin + plugin, err := extism.NewPlugin(ctx, manifest, cfg, []extism.HostFunction{}) + if err != nil { + p.AddError(file.Path, fmt.Sprintf("failed to load WASM module: %v", err), 0) + return + } + defer plugin.Close(ctx) + + // Check if Execute function is exported + if !plugin.FunctionExists("Execute") { + p.AddError(file.Path, "WASM module missing required 'Execute' function export", 0) + } +} + func (p *PolicyToLint) validateAndFormatRego(content, path string) string { // 1. Optionally format if p.Format { diff --git a/app/cli/internal/policydevel/templates/example-policy.yaml b/app/cli/internal/policydevel/templates/example-policy.yaml index d626324b1..cfa871f86 100644 --- a/app/cli/internal/policydevel/templates/example-policy.yaml +++ b/app/cli/internal/policydevel/templates/example-policy.yaml @@ -18,7 +18,8 @@ spec: embedded: | {{.RegoContent | indent 8}} {{else -}} - # Path to external Rego policy file - # See docs: https://docs.chainloop.dev/guides/custom-policies#rego-policy-structure + # Path to external policy file (Rego or WASM) + # Rego: https://docs.chainloop.dev/guides/custom-policies#rego-policy-structure + # WASM: https://docs.chainloop.dev/guides/custom-policies#wasm-policy-structure path: {{.RegoPath}} {{end -}} diff --git a/go.mod b/go.mod index 2cfeb3ccd..0a1e20fbc 100644 --- a/go.mod +++ b/go.mod @@ -72,6 +72,7 @@ require ( github.com/bufbuild/protoyaml-go v0.1.11 github.com/casbin/casbin/v2 v2.103.0 github.com/denisbrodbeck/machineid v1.0.1 + github.com/extism/go-sdk v1.7.1 github.com/google/go-github/v66 v66.0.0 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 @@ -145,6 +146,7 @@ require ( github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect @@ -168,6 +170,7 @@ require ( github.com/gorilla/handlers v1.5.1 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/yamux v0.1.2 // indirect + github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jellydator/ttlcache/v3 v3.3.0 // indirect @@ -209,6 +212,8 @@ require ( github.com/stoewer/go-strcase v1.3.0 // indirect github.com/styrainc/roast v0.15.0 // indirect github.com/tchap/go-patricia/v2 v2.3.2 // indirect + github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect + github.com/tetratelabs/wazero v1.9.0 // indirect github.com/theupdateframework/go-tuf/v2 v2.0.1 // indirect github.com/tklauser/go-sysconf v0.3.14 // indirect github.com/tklauser/numcpus v0.9.0 // indirect @@ -230,6 +235,7 @@ require ( go.opentelemetry.io/otel/metric v1.36.0 // indirect go.opentelemetry.io/otel/sdk v1.36.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect + go.opentelemetry.io/proto/otlp v1.6.0 // indirect go.step.sm/crypto v0.51.2 // indirect goa.design/goa v2.2.5+incompatible // indirect gopkg.in/warnings.v0 v0.1.2 // indirect diff --git a/go.sum b/go.sum index 5919a7903..6756dc905 100644 --- a/go.sum +++ b/go.sum @@ -379,6 +379,8 @@ github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a h1:UwSIFv5g5lIvbGgtf3tVwC7Ky9rmMFBp0RMs+6f6YqE= +github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= @@ -406,6 +408,8 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJP github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= +github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= @@ -724,6 +728,8 @@ github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca h1:T54Ema1DU8ngI+aef9ZhAhNGQhcRTrWxVeG07F+c/Rw= +github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/improbable-eng/grpc-web v0.15.0 h1:BN+7z6uNXZ1tQGcNAuaU1YjsLTApzkjt2tzCixLaUPQ= @@ -1227,6 +1233,10 @@ github.com/tchap/go-patricia/v2 v2.3.2 h1:xTHFutuitO2zqKAQ5rCROYgUb7Or/+IC3fts9/ github.com/tchap/go-patricia/v2 v2.3.2/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo= github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4= +github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= +github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk= +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= github.com/thales-e-security/pool v0.0.2 h1:RAPs4q2EbWsTit6tpzuvTFlgFRJ3S8Evf5gtvVDbmPg= github.com/thales-e-security/pool v0.0.2/go.mod h1:qtpMm2+thHtqhLzTwgDBj/OuNnMpupY8mv0Phz0gjhU= github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= diff --git a/pkg/policies/engine/builtins/discover.go b/pkg/policies/engine/builtins/discover.go new file mode 100644 index 000000000..b328680d3 --- /dev/null +++ b/pkg/policies/engine/builtins/discover.go @@ -0,0 +1,46 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package builtins + +import ( + "context" + + v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" + "google.golang.org/grpc" +) + +// DiscoverService wraps the gRPC discover functionality to be shared across engines +type DiscoverService struct { + conn *grpc.ClientConn +} + +// NewDiscoverService creates a new discover service +func NewDiscoverService(conn *grpc.ClientConn) *DiscoverService { + return &DiscoverService{conn: conn} +} + +// Discover calls the DiscoverPrivate gRPC endpoint to get artifact graph data +func (s *DiscoverService) Discover(ctx context.Context, digest, kind string) (*v1.ReferrerServiceDiscoverPrivateResponse, error) { + if s.conn == nil { + return nil, nil + } + + client := v1.NewReferrerServiceClient(s.conn) + return client.DiscoverPrivate(ctx, &v1.ReferrerServiceDiscoverPrivateRequest{ + Digest: digest, + Kind: kind, + }) +} diff --git a/pkg/policies/engine/builtins/rego.go b/pkg/policies/engine/builtins/rego.go new file mode 100644 index 000000000..2c6f1ac26 --- /dev/null +++ b/pkg/policies/engine/builtins/rego.go @@ -0,0 +1,105 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package builtins + +import ( + "errors" + "fmt" + + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/topdown" + "github.com/open-policy-agent/opa/v1/types" + "google.golang.org/grpc" +) + +const discoverBuiltinName = "chainloop.discover" + +// RegisterDiscoverBuiltin registers chainloop's Discover endpoint as a builtin Rego function with signature: +// +// chainloop.discover(digest, kind) +// +// For instance, to get the references for an CONTAINER_IMAGE material, and fail if any of them is an attestation with policy violations: +// ``` +// +// violations contains msg if { +// digest := sprintf("sha256:%s",[input.chainloop_metadata.digest.sha256]) +// discovered := chainloop.discover(digest, "") +// +// some ref in discovered.references +// ref.kind == "ATTESTATION" +// ref.metadata.hasPolicyViolations == "true" +// +// msg:= sprintf("attestation with digest %s contains policy violations [name: %s, project: %s, org: %s]", [ref.digest, ref.metadata.name, ref.metadata.project, ref.metadata.organization]) +// } +// +// ``` +func RegisterDiscoverBuiltin(conn *grpc.ClientConn) error { + return register(&ast.Builtin{ + Name: discoverBuiltinName, + Description: "Discovers artifact graph data by calling the Referrer chainloop service", + Decl: types.NewFunction( + types.Args( + types.Named("digest", types.S).Description("digest of the artifact to discover"), + types.Named("kind", types.S).Description("optional filter by kind to disambiguate"), + ), + types.Named("response", types.A).Description("response object as in the `chainloop discover` CLI output"), + ), + Nondeterministic: true, + }, getDiscoverRegoImpl(conn)) +} + +func getDiscoverRegoImpl(conn *grpc.ClientConn) topdown.BuiltinFunc { + discoverSvc := NewDiscoverService(conn) + + return func(bctx topdown.BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error { + if len(operands) < 1 { + return errors.New("need at least one operand") + } + + var digest, kind ast.String + var ok bool + + // Extract digest + digest, ok = operands[0].Value.(ast.String) + if !ok { + return errors.New("digest must be a string") + } + + if len(operands) > 1 { + // Extract kind + kind, ok = operands[1].Value.(ast.String) + if !ok { + return errors.New("kind must be a string") + } + } + + // Call the shared service + resp, err := discoverSvc.Discover(bctx.Context, string(digest), string(kind)) + if err != nil { + return fmt.Errorf("failed to call discover endpoint: %w", err) + } + + // call the iterator with the output value + return iter(ast.NewTerm(ast.MustInterfaceToValue(resp.Result))) + } +} + +// register is a wrapper around topdown.RegisterBuiltinFunc to handle registration +func register(builtin *ast.Builtin, impl topdown.BuiltinFunc) error { + topdown.RegisterBuiltinFunc(builtin.Name, impl) + ast.RegisterBuiltin(builtin) + return nil +} diff --git a/pkg/policies/engine/builtins/wasm.go b/pkg/policies/engine/builtins/wasm.go new file mode 100644 index 000000000..18f5b715c --- /dev/null +++ b/pkg/policies/engine/builtins/wasm.go @@ -0,0 +1,81 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package builtins + +import ( + "context" + "encoding/json" + + extism "github.com/extism/go-sdk" + "google.golang.org/grpc" +) + +// CreateDiscoverHostFunction creates an Extism host function for the discover builtin +// This allows WASM policies to call chainloop_discover(digest, kind) and get artifact graph data +func CreateDiscoverHostFunction(conn *grpc.ClientConn) extism.HostFunction { + discoverSvc := NewDiscoverService(conn) + + return extism.NewHostFunctionWithStack( + "chainloop_discover", + func(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64) { + // Read digest from WASM memory + digestOffset := stack[0] + digest, err := plugin.ReadString(digestOffset) + if err != nil { + // Return 0 to signal error + stack[0] = 0 + return + } + + // Read kind from WASM memory (if provided) + var kind string + if len(stack) > 1 && stack[1] != 0 { + kindOffset := stack[1] + kind, _ = plugin.ReadString(kindOffset) + } + + // Call shared discover service + resp, err := discoverSvc.Discover(ctx, digest, kind) + if err != nil { + // Return 0 to signal error + stack[0] = 0 + return + } + + // Serialize response to JSON + jsonData, err := json.Marshal(resp.Result) + if err != nil { + // Return 0 to signal error + stack[0] = 0 + return + } + + // Write JSON to WASM memory and return offset + offset, err := plugin.WriteBytes(jsonData) + if err != nil { + // Return 0 to signal error + stack[0] = 0 + return + } + + stack[0] = offset + }, + // inputs: digest offset, kind offset + []extism.ValueType{extism.ValueTypeI64, extism.ValueTypeI64}, + // output: json result offset or 0 on error + []extism.ValueType{extism.ValueTypeI64}, + ) +} diff --git a/pkg/policies/engine/detect.go b/pkg/policies/engine/detect.go new file mode 100644 index 000000000..bda00bbb5 --- /dev/null +++ b/pkg/policies/engine/detect.go @@ -0,0 +1,43 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package engine + +// PolicyType represents the type of a policy (Rego or WASM) +type PolicyType string + +const ( + // PolicyTypeRego indicates a Rego-based policy + PolicyTypeRego PolicyType = "rego" + // PolicyTypeWASM indicates a WASM-based policy + PolicyTypeWASM PolicyType = "wasm" +) + +// DetectPolicyType determines the policy type from source bytes +// WASM files start with magic bytes: 0x00 0x61 0x73 0x6d (\0asm) +// as documented at https://webassembly.github.io/spec/core/binary/modules.html#binary-module +func DetectPolicyType(source []byte) PolicyType { + // WASM files start with magic bytes: 0x00 0x61 0x73 0x6d (\0asm) + if len(source) >= 4 && + source[0] == 0x00 && + source[1] == 0x61 && + source[2] == 0x73 && + source[3] == 0x6d { + return PolicyTypeWASM + } + + // Default to Rego (text-based) + return PolicyTypeRego +} diff --git a/pkg/policies/engine/detect_test.go b/pkg/policies/engine/detect_test.go new file mode 100644 index 000000000..19fd521f8 --- /dev/null +++ b/pkg/policies/engine/detect_test.go @@ -0,0 +1,68 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package engine + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDetectPolicyType(t *testing.T) { + tests := []struct { + name string + source []byte + expected PolicyType + }{ + { + name: "WASM magic bytes", + source: []byte{0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00}, + expected: PolicyTypeWASM, + }, + { + name: "Rego policy", + source: []byte("package main\n\nresult = {\"violations\": []}"), + expected: PolicyTypeRego, + }, + { + name: "Empty file", + source: []byte{}, + expected: PolicyTypeRego, + }, + { + name: "Partial WASM magic bytes (only 3 bytes)", + source: []byte{0x00, 0x61, 0x73}, + expected: PolicyTypeRego, + }, + { + name: "Incorrect magic bytes", + source: []byte{0x00, 0x61, 0x73, 0x6e}, + expected: PolicyTypeRego, + }, + { + name: "Binary data that's not WASM", + source: []byte{0xFF, 0xD8, 0xFF, 0xE0}, // JPEG magic bytes + expected: PolicyTypeRego, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DetectPolicyType(tt.source) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/policies/engine/engine.go b/pkg/policies/engine/engine.go index d1f36c9c3..1c32af6af 100644 --- a/pkg/policies/engine/engine.go +++ b/pkg/policies/engine/engine.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2025 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -19,8 +19,118 @@ import ( "context" "encoding/json" "fmt" + "time" + + "github.com/rs/zerolog" + "google.golang.org/grpc" ) +// BaseAllowedHostnames are the default hostnames allowed for HTTP requests in policies +var BaseAllowedHostnames = []string{ + "www.chainloop.dev", + "www.cisa.gov", +} + +// CommonEngineOptions contains configuration options shared by all policy engines +type CommonEngineOptions struct { + AllowedHostnames []string + IncludeRawData bool + EnablePrint bool + ControlPlaneConnection *grpc.ClientConn +} + +// Option is a unified functional option for configuring policy engines +type Option func(*Options) + +// Options contains all configuration options for policy engines +type Options struct { + // Common options + *CommonEngineOptions + + // Rego-specific options + // OperatingMode defines whether the Rego engine runs in restrictive (0) or permissive (1) mode + OperatingMode int32 + + // WASM-specific options + ExecutionTimeout time.Duration + Logger *zerolog.Logger +} + +// WithAllowedHostnames sets the list of allowed hostnames for HTTP requests +// User-provided hostnames are appended to BaseAllowedHostnames +func WithAllowedHostnames(hostnames ...string) Option { + return func(opts *Options) { + opts.AllowedHostnames = append(opts.AllowedHostnames, hostnames...) + } +} + +// WithIncludeRawData sets whether to include raw input/output data in results +func WithIncludeRawData(include bool) Option { + return func(opts *Options) { + opts.IncludeRawData = include + } +} + +// WithEnablePrint enables print/log statements in policies +func WithEnablePrint(enable bool) Option { + return func(opts *Options) { + opts.EnablePrint = enable + } +} + +// WithOperatingMode sets the Rego engine operating mode (restrictive or permissive) +func WithOperatingMode(mode int32) Option { + return func(opts *Options) { + opts.OperatingMode = mode + } +} + +// WithExecutionTimeout sets the WASM execution timeout +func WithExecutionTimeout(timeout time.Duration) Option { + return func(opts *Options) { + opts.ExecutionTimeout = timeout + } +} + +// WithLogger sets the WASM engine logger +func WithLogger(logger *zerolog.Logger) Option { + return func(opts *Options) { + opts.Logger = logger + } +} + +// WithGRPCConn sets the gRPC connection for builtin functions like discover +func WithGRPCConn(conn *grpc.ClientConn) Option { + return func(opts *Options) { + opts.ControlPlaneConnection = conn + } +} + +// ApplyOptions applies options and returns the configured Options +// This automatically appends BaseAllowedHostnames to any user-provided hostnames +func ApplyOptions(opts ...Option) *Options { + options := &Options{ + CommonEngineOptions: &CommonEngineOptions{ + AllowedHostnames: make([]string, 0), + IncludeRawData: false, + EnablePrint: false, + ControlPlaneConnection: nil, + }, + OperatingMode: 0, // Default restrictive mode + ExecutionTimeout: 0, + Logger: nil, + } + + for _, opt := range opts { + opt(options) + } + + // Append base allowed hostnames to user-provided ones + options.AllowedHostnames = append(options.AllowedHostnames, BaseAllowedHostnames...) + + return options +} + type PolicyEngine interface { // Verify verifies an input against a policy Verify(ctx context.Context, policy *Policy, input []byte, args map[string]any) (*EvaluationResult, error) diff --git a/pkg/policies/engine/rego/rego.go b/pkg/policies/engine/rego/rego.go index d437ee067..cd75ce9a1 100644 --- a/pkg/policies/engine/rego/rego.go +++ b/pkg/policies/engine/rego/rego.go @@ -35,72 +35,20 @@ type Engine struct { // operatingMode defines the mode of running the policy engine // by restricting or not the operations allowed by the compiler operatingMode EnvironmentMode - // allowedNetworkDomains is a list of network domains that are allowed for the compiler to access - // when using http.send built-in function - allowedNetworkDomains []string - // includeRawData determines whether to collect raw evaluation data - includeRawData bool - // enablePrint determines whether to enable print statements in rego policies - enablePrint bool -} - -type EngineOption func(*newEngineOptions) - -func WithOperatingMode(mode EnvironmentMode) EngineOption { - return func(e *newEngineOptions) { - e.operatingMode = mode - } -} - -func WithAllowedNetworkDomains(domains ...string) EngineOption { - return func(e *newEngineOptions) { - e.allowedNetworkDomains = domains - } -} - -func WithIncludeRawData(include bool) EngineOption { - return func(e *newEngineOptions) { - e.includeRawData = include - } -} - -func WithEnablePrint(enable bool) EngineOption { - return func(e *newEngineOptions) { - e.enablePrint = enable - } -} - -type newEngineOptions struct { - operatingMode EnvironmentMode - allowedNetworkDomains []string - includeRawData bool - enablePrint bool + // Embed common engine options + *engine.CommonEngineOptions } // NewEngine creates a new policy engine with the given options // default operating mode is EnvironmentModeRestrictive -// default allowed network domains are www.chainloop.dev and www.cisa.gov -// user provided allowed network domains are appended to the base ones -func NewEngine(opts ...EngineOption) *Engine { - options := &newEngineOptions{ - operatingMode: EnvironmentModeRestrictive, - } - - for _, opt := range opts { - opt(options) - } - - var baseAllowedNetworkDomains = []string{ - "www.chainloop.dev", - "www.cisa.gov", - } +// default allowed hostnames are www.chainloop.dev and www.cisa.gov +// user provided allowed hostnames are appended to the base ones +func NewEngine(opts ...engine.Option) *Engine { + options := engine.ApplyOptions(opts...) return &Engine{ - operatingMode: options.operatingMode, - // append base allowed network domains to the user provided ones - allowedNetworkDomains: append(baseAllowedNetworkDomains, options.allowedNetworkDomains...), - includeRawData: options.includeRawData, - enablePrint: options.enablePrint, + operatingMode: EnvironmentMode(options.OperatingMode), + CommonEngineOptions: options.CommonEngineOptions, } } @@ -184,7 +132,7 @@ func (r *Engine) Verify(ctx context.Context, policy *engine.Policy, input []byte options := []func(r *rego.Rego){regoInput, regoFunc, rego.Capabilities(r.Capabilities())} // Add print support if enabled - if r.enablePrint { + if r.EnablePrint { options = append(options, rego.EnablePrintStatements(true), rego.PrintHook(®oOutputHook{}), @@ -201,7 +149,7 @@ func (r *Engine) Verify(ctx context.Context, policy *engine.Policy, input []byte var rawData *engine.RawData // Get raw results first if requested - if r.includeRawData { + if r.IncludeRawData { if err := executeQuery(getRuleName(parsedModule.Package.Path, ""), r.operatingMode == EnvironmentModeRestrictive); err != nil { return nil, err } @@ -322,7 +270,7 @@ func (r *Engine) Capabilities() *ast.Capabilities { } // Allow specific network domains - capabilities.AllowNet = r.allowedNetworkDomains + capabilities.AllowNet = r.AllowedHostnames case EnvironmentModePermissive: enabledBuiltin = capabilities.Builtins diff --git a/pkg/policies/engine/rego/rego_test.go b/pkg/policies/engine/rego/rego_test.go index c4bca2fca..c92025ac3 100644 --- a/pkg/policies/engine/rego/rego_test.go +++ b/pkg/policies/engine/rego/rego_test.go @@ -297,7 +297,7 @@ func TestRego_WithRestrictiveMode(t *testing.T) { customHosts, err := os.ReadFile("testfiles/restrictive_mode_networking.rego") require.NoError(t, err) - r := NewEngine(WithAllowedNetworkDomains("github.com")) + r := NewEngine(engine.WithAllowedHostnames("github.com")) policy := &engine.Policy{ Name: "policy", Source: defaultHosts, @@ -320,7 +320,7 @@ func TestRego_WithPermissiveMode(t *testing.T) { regoContent, err := os.ReadFile("testfiles/permissive_mode.rego") require.NoError(t, err) - r := NewEngine(WithOperatingMode(EnvironmentModePermissive)) + r := NewEngine(engine.WithOperatingMode(int32(EnvironmentModePermissive))) policy := &engine.Policy{ Name: "policy", Source: regoContent, @@ -444,7 +444,7 @@ func TestRego_CustomBuiltinsPermissiveMode(t *testing.T) { require.NoError(t, builtins.RegisterHelloBuiltin()) // Create engine in permissive mode - r := NewEngine(WithOperatingMode(EnvironmentModePermissive)) + r := NewEngine(engine.WithOperatingMode(int32(EnvironmentModePermissive))) policy := &engine.Policy{ Name: "custom builtin test", Source: regoContent, diff --git a/pkg/policies/engine/wasm/engine.go b/pkg/policies/engine/wasm/engine.go new file mode 100644 index 000000000..feac6f091 --- /dev/null +++ b/pkg/policies/engine/wasm/engine.go @@ -0,0 +1,237 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wasm + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/chainloop-dev/chainloop/pkg/policies/engine" + "github.com/chainloop-dev/chainloop/pkg/policies/engine/builtins" + + extism "github.com/extism/go-sdk" + "github.com/rs/zerolog" +) + +// Ensure Engine implements PolicyEngine interface +var _ engine.PolicyEngine = (*Engine)(nil) + +// entryFunction is the name of the function to call in the WASM module. +// The engine expects this function to be present. +const entryFunction = "Execute" + +// Engine implements the PolicyEngine interface for WASM policies +type Engine struct { + executionTimeout time.Duration + logger *zerolog.Logger + // Embed common engine options + *engine.CommonEngineOptions +} + +// NewEngine creates a new WASM policy engine with the given options +func NewEngine(opts ...engine.Option) *Engine { + options := engine.ApplyOptions(opts...) + + // Extract WASM-specific options with defaults + executionTimeout := options.ExecutionTimeout + if executionTimeout == 0 { + executionTimeout = 5 * time.Second + } + + logger := options.Logger + if logger == nil { + noopLogger := zerolog.Nop() + logger = &noopLogger + } + + return &Engine{ + executionTimeout: executionTimeout, + logger: logger, + CommonEngineOptions: options.CommonEngineOptions, + } +} + +// Verify executes a WASM policy against the provided input +func (e *Engine) Verify(ctx context.Context, policy *engine.Policy, input []byte, args map[string]any) (*engine.EvaluationResult, error) { + e.logger.Debug().Str("policy", policy.Name).Int("wasm_size", len(policy.Source)).Int("input_size", len(input)).Int("args_count", len(args)).Msg("Starting WASM policy execution") + + // Enable WASM plugin logging based on logger level + // This allows LogInfo(), LogDebug(), etc. from the WASM policy to be visible + switch { + case e.logger.GetLevel() <= zerolog.TraceLevel: + extism.SetLogLevel(extism.LogLevelTrace) + case e.logger.GetLevel() <= zerolog.DebugLevel: + extism.SetLogLevel(extism.LogLevelDebug) + case e.logger.GetLevel() <= zerolog.InfoLevel: + extism.SetLogLevel(extism.LogLevelInfo) + case e.logger.GetLevel() <= zerolog.WarnLevel: + extism.SetLogLevel(extism.LogLevelWarn) + default: + extism.SetLogLevel(extism.LogLevelError) + } + + // Prepare config with args if present + configMap := make(map[string]string) + if len(args) > 0 { + // Marshal args to JSON and store in config + argsJSON, err := json.Marshal(args) + if err != nil { + return nil, fmt.Errorf("failed to marshal args: %w", err) + } + configMap["args"] = string(argsJSON) + e.logger.Debug().Str("policy", policy.Name).Int("args_count", len(args)).Msg("Passing policy arguments via Extism config") + } + + // Create Extism manifest with config and allowed hosts + manifest := extism.Manifest{ + Wasm: []extism.Wasm{ + extism.WasmData{Data: policy.Source}, + }, + Config: configMap, + AllowedHosts: e.AllowedHostnames, + } + + // Log allowed hosts configuration + if len(e.AllowedHostnames) > 0 { + e.logger.Debug().Str("policy", policy.Name).Int("allowed_hosts", len(e.AllowedHostnames)).Strs("hostnames", e.AllowedHostnames).Msg("Configured allowed hosts for HTTP requests") + } + + config := extism.PluginConfig{ + EnableWasi: true, + } + + // Register host functions + var hostFunctions []extism.HostFunction + if e.ControlPlaneConnection != nil { + hostFunctions = append(hostFunctions, builtins.CreateDiscoverHostFunction(e.ControlPlaneConnection)) + } + + // Create plugin with host functions + plugin, err := extism.NewPlugin(ctx, manifest, config, hostFunctions) + if err != nil { + e.logger.Error().Err(err).Str("policy", policy.Name).Msg("Failed to create WASM plugin") + return nil, fmt.Errorf("failed to create WASM plugin: %w", err) + } + defer plugin.Close(ctx) + + // Check if Execute function is exported + if !plugin.FunctionExists(entryFunction) { + e.logger.Error().Str("policy", policy.Name).Str("function", entryFunction).Msg("WASM module missing required function export") + return nil, fmt.Errorf("wasm module validation failed: missing required '%s' function export", entryFunction) + } + + // Set up logger for WASM plugin output + plugin.SetLogger(func(level extism.LogLevel, message string) { + switch level { + case extism.LogLevelTrace: + e.logger.Trace().Str("policy", policy.Name).Msg(message) + case extism.LogLevelDebug: + e.logger.Debug().Str("policy", policy.Name).Msg(message) + case extism.LogLevelInfo: + e.logger.Info().Str("policy", policy.Name).Msg(message) + case extism.LogLevelWarn: + e.logger.Warn().Str("policy", policy.Name).Msg(message) + case extism.LogLevelError: + e.logger.Error().Str("policy", policy.Name).Msg(message) + } + }) + + e.logger.Debug().Str("policy", policy.Name).Msg("WASM plugin created successfully") + + // Execute with timeout + execCtx, cancel := context.WithTimeout(ctx, e.executionTimeout) + defer cancel() + + e.logger.Debug().Str("policy", policy.Name).Dur("timeout", e.executionTimeout).Msg("Executing WASM policy with raw material input") + + // Pass raw material bytes as input (args are in config) + exit, output, err := plugin.CallWithContext(execCtx, entryFunction, input) + if err != nil { + // Parse the error to provide user-friendly messages + parsedErr := parseWasmError(err) + + // Log with error category for debugging + e.logger.Debug().Err(err).Str("policy", policy.Name).Uint32("exit_code", exit).Str("error_category", string(parsedErr.Category)).Msg("WASM policy execution failed") + + // In debug mode, also log the original error with full details + if e.logger.GetLevel() <= zerolog.DebugLevel { + e.logger.Debug().Str("policy", policy.Name).Str("original_error", parsedErr.OriginalErr.Error()).Msg("Original WASM error (for debugging)") + } + + // Return user-friendly error message + return nil, fmt.Errorf("policy execution failed: %w", parsedErr) + } + + e.logger.Debug().Str("policy", policy.Name).Int("output_size", len(output)).Msg("WASM policy execution completed") + + // Parse output + var result struct { + Skipped bool `json:"skipped"` + Violations []string `json:"violations"` + SkipReason string `json:"skip_reason"` + Ignore bool `json:"ignore"` + } + + if err := json.Unmarshal(output, &result); err != nil { + e.logger.Error().Err(err).Str("policy", policy.Name).Str("output", string(output)).Msg("Failed to parse WASM policy output") + return nil, fmt.Errorf("failed to parse policy output: %w", err) + } + + // Convert to engine.EvaluationResult + evalResult := &engine.EvaluationResult{ + Skipped: result.Skipped, + SkipReason: result.SkipReason, + Ignore: result.Ignore, + Violations: make([]*engine.PolicyViolation, 0, len(result.Violations)), + } + + for _, v := range result.Violations { + evalResult.Violations = append(evalResult.Violations, &engine.PolicyViolation{ + Subject: policy.Name, + Violation: v, + }) + } + + e.logger.Debug().Str("policy", policy.Name).Int("violations", len(evalResult.Violations)).Bool("skipped", evalResult.Skipped).Msg("WASM policy evaluation complete") + + // Include raw data if requested + if e.IncludeRawData { + evalResult.RawData = &engine.RawData{ + Input: input, + Output: output, + } + } + + return evalResult, nil +} + +// MatchesParameters is a stub implementation for WASM policies +// WASM policies don't currently support parameter matching +func (e *Engine) MatchesParameters(_ context.Context, _ *engine.Policy, + _, _ map[string]string) (bool, error) { + // Default to true - WASM policies handle their own parameter validation + return true, nil +} + +// MatchesEvaluation is a stub implementation for WASM policies +// WASM policies don't currently support evaluation matching +func (e *Engine) MatchesEvaluation(_ context.Context, _ *engine.Policy, + _ []string, _ map[string]string) (bool, error) { + // Default to true - WASM policies handle their own evaluation logic + return true, nil +} diff --git a/pkg/policies/engine/wasm/engine_test.go b/pkg/policies/engine/wasm/engine_test.go new file mode 100644 index 000000000..88e804fe3 --- /dev/null +++ b/pkg/policies/engine/wasm/engine_test.go @@ -0,0 +1,111 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wasm + +import ( + "context" + "testing" + "time" + + "github.com/chainloop-dev/chainloop/pkg/policies/engine" + "github.com/stretchr/testify/assert" +) + +func TestNewEngine(t *testing.T) { + t.Run("default options", func(t *testing.T) { + eng := NewEngine() + assert.NotNil(t, eng) + assert.Equal(t, 5*time.Second, eng.executionTimeout) + assert.False(t, eng.IncludeRawData) + assert.False(t, eng.EnablePrint) + // Base allowed hostnames should be included by default + assert.Contains(t, eng.AllowedHostnames, "www.chainloop.dev") + assert.Contains(t, eng.AllowedHostnames, "www.cisa.gov") + }) + + t.Run("with custom options", func(t *testing.T) { + eng := NewEngine( + engine.WithExecutionTimeout(30*time.Second), + engine.WithIncludeRawData(true), + engine.WithEnablePrint(true), + engine.WithAllowedHostnames("api.example.com"), + ) + assert.NotNil(t, eng) + assert.Equal(t, 30*time.Second, eng.executionTimeout) + assert.True(t, eng.IncludeRawData) + assert.True(t, eng.EnablePrint) + // Should include both custom and base hostnames + assert.Contains(t, eng.AllowedHostnames, "api.example.com") + assert.Contains(t, eng.AllowedHostnames, "www.chainloop.dev") + assert.Contains(t, eng.AllowedHostnames, "www.cisa.gov") + }) +} + +func TestEngineVerify_InvalidWASM(t *testing.T) { + eng := NewEngine() + + ctx := context.Background() + policy := &engine.Policy{ + Name: "test-policy", + Source: []byte("not valid wasm"), + } + input := []byte(`{"test": "data"}`) + + result, err := eng.Verify(ctx, policy, input, nil) + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "failed to create WASM plugin") +} + +func TestEngineVerify_InvalidJSON(t *testing.T) { + // This test would require a valid WASM module + // Skipping for now as we need TinyGo to compile test WASM modules + t.Skip("Requires compiled WASM test module") +} + +func TestEngineImplementsPolicyEngine(_ *testing.T) { + var _ engine.PolicyEngine = (*Engine)(nil) +} + +func TestMatchesParameters(t *testing.T) { + eng := NewEngine() + + ctx := context.Background() + policy := &engine.Policy{ + Name: "test-policy", + Source: []byte{}, + } + + // Should always return true for WASM policies + matches, err := eng.MatchesParameters(ctx, policy, map[string]string{"key": "value"}, map[string]string{"key": "value"}) + assert.NoError(t, err) + assert.True(t, matches) +} + +func TestMatchesEvaluation(t *testing.T) { + eng := NewEngine() + + ctx := context.Background() + policy := &engine.Policy{ + Name: "test-policy", + Source: []byte{}, + } + + // Should always return true for WASM policies + matches, err := eng.MatchesEvaluation(ctx, policy, []string{"violation"}, map[string]string{"key": "value"}) + assert.NoError(t, err) + assert.True(t, matches) +} diff --git a/pkg/policies/engine/wasm/errors.go b/pkg/policies/engine/wasm/errors.go new file mode 100644 index 000000000..a17c15727 --- /dev/null +++ b/pkg/policies/engine/wasm/errors.go @@ -0,0 +1,178 @@ +// +// Copyright 2024-2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wasm + +import ( + "fmt" + "regexp" + "strings" +) + +// ErrorCategory represents the type of WASM policy error +type ErrorCategory string + +const ( + CategoryHTTPForbidden ErrorCategory = "http_forbidden" + CategoryTimeout ErrorCategory = "timeout" + CategoryMemory ErrorCategory = "memory" + CategoryPanic ErrorCategory = "panic" + CategoryUnknown ErrorCategory = "unknown" +) + +// PolicyError represents a parsed WASM policy error with user-friendly messaging +type PolicyError struct { + Category ErrorCategory + UserMessage string + Hint string + OriginalErr error +} + +// Error patterns for common WASM execution errors +var errorPatterns = []struct { + pattern *regexp.Regexp + category ErrorCategory + extract func(matches []string) (message, hint string) +}{ + { + // HTTP request to disallowed hostname + pattern: regexp.MustCompile(`HTTP request to '(.+?)' is not allowed`), + category: CategoryHTTPForbidden, + extract: func(matches []string) (string, string) { + url := matches[1] + hostname := extractHostname(url) + message := fmt.Sprintf("HTTP request blocked - hostname '%s' is not in the allowed hosts list", hostname) + hint := fmt.Sprintf("Add the hostname using --allowed-hostnames flag or configure it in your policy engine.\nAttempted URL: %s", url) + return message, hint + }, + }, + { + // Alternative HTTP forbidden pattern + pattern: regexp.MustCompile(`Host not allowed`), + category: CategoryHTTPForbidden, + extract: func(_ []string) (string, string) { + message := "HTTP request blocked - hostname is not in the allowed hosts list" + hint := "Add the hostname using --allowed-hostnames flag or configure it in your policy engine." + return message, hint + }, + }, + { + // Execution timeout + pattern: regexp.MustCompile(`context deadline exceeded`), + category: CategoryTimeout, + extract: func(_ []string) (string, string) { + message := "Policy execution timeout exceeded" + hint := "The policy took too long to execute. Consider optimizing network calls, reducing data processing, or increasing the timeout with WithExecutionTimeout()." + return message, hint + }, + }, + { + // Out of memory + pattern: regexp.MustCompile(`out of memory`), + category: CategoryMemory, + extract: func(_ []string) (string, string) { + message := "Policy exceeded memory limits" + hint := "Review data structures and avoid loading large files entirely into memory. Consider streaming or processing data in chunks." + return message, hint + }, + }, + { + // Runtime panic with specific error + pattern: regexp.MustCompile(`runtime error: ([^\(]+?)(?:\s+\(recovered by wazero\)|$)`), + category: CategoryPanic, + extract: func(matches []string) (string, string) { + runtimeErr := strings.TrimSpace(matches[1]) + message := fmt.Sprintf("Runtime error in policy: %s", runtimeErr) + hint := "The policy encountered an unexpected error. Enable debug logging with --debug to see detailed stack traces." + return message, hint + }, + }, +} + +// parseWasmError parses a WASM execution error into a user-friendly PolicyError +func parseWasmError(err error) *PolicyError { + if err == nil { + return nil + } + + errStr := err.Error() + + // Try pattern matching for known error types + for _, ep := range errorPatterns { + if matches := ep.pattern.FindStringSubmatch(errStr); matches != nil { + message, hint := ep.extract(matches) + return &PolicyError{ + Category: ep.category, + UserMessage: message, + Hint: hint, + OriginalErr: err, + } + } + } + + // Fallback: extract first meaningful line before stack trace + lines := strings.Split(errStr, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + + // Stop at stack trace markers + if line == "" || strings.HasPrefix(line, "wasm stack trace:") || strings.HasPrefix(line, "\t") { + break + } + + // Clean up wazero artifacts + line = strings.ReplaceAll(line, " (recovered by wazero)", "") + line = strings.TrimSpace(line) + + if line != "" { + return &PolicyError{ + Category: CategoryUnknown, + UserMessage: line, + Hint: "Enable debug logging with --debug for more details.", + OriginalErr: err, + } + } + } + + // Ultimate fallback + return &PolicyError{ + Category: CategoryUnknown, + UserMessage: "Policy execution failed", + Hint: "Enable debug logging with --debug for detailed error information.", + OriginalErr: err, + } +} + +// extractHostname extracts the hostname from a URL string +func extractHostname(urlStr string) string { + // Remove protocol + urlStr = strings.TrimPrefix(urlStr, "https://") + urlStr = strings.TrimPrefix(urlStr, "http://") + + // Extract hostname (everything before first / or :) + if idx := strings.IndexAny(urlStr, "/:"); idx != -1 { + return urlStr[:idx] + } + + return urlStr +} + +// Error implements the error interface for PolicyError +func (e *PolicyError) Error() string { + if e.Hint != "" { + return fmt.Sprintf("%s\n\nHint: %s", e.UserMessage, e.Hint) + } + return e.UserMessage +} diff --git a/pkg/policies/engine/wasm/errors_test.go b/pkg/policies/engine/wasm/errors_test.go new file mode 100644 index 000000000..cac639255 --- /dev/null +++ b/pkg/policies/engine/wasm/errors_test.go @@ -0,0 +1,327 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wasm + +import ( + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +//nolint:revive // Test error strings intentionally match real wazero error format +func TestParseWasmError(t *testing.T) { + tests := []struct { + name string + input error + expectedCategory ErrorCategory + expectedMessage string + expectedHint string + }{ + { + name: "HTTP request to disallowed hostname with full URL", + input: errors.New(`HTTP request to 'https://httpbin.org/json' is not allowed (recovered by wazero) +wasm stack trace: + extism:host/env.http_request(i64,i64) i64 + main.Execute() i32`), + expectedCategory: CategoryHTTPForbidden, + expectedMessage: "HTTP request blocked - hostname 'httpbin.org' is not in the allowed hosts list", + expectedHint: "Add the hostname using --allowed-hostnames flag", + }, + { + name: "HTTP request to disallowed hostname without wazero message", + //nolint:revive // Test error string matches real wazero error format + input: errors.New(`HTTP request to 'https://evil.com/malware' is not allowed +wasm stack trace: + ...`), + expectedCategory: CategoryHTTPForbidden, + expectedMessage: "HTTP request blocked - hostname 'evil.com' is not in the allowed hosts list", + expectedHint: "Add the hostname using --allowed-hostnames flag", + }, + { + name: "Generic host not allowed", + //nolint:revive // Test error string matches real wazero error format + input: errors.New(`Host not allowed (recovered by wazero) +wasm stack trace: + ...`), + expectedCategory: CategoryHTTPForbidden, + expectedMessage: "HTTP request blocked - hostname is not in the allowed hosts list", + expectedHint: "Add the hostname using --allowed-hostnames flag", + }, + { + name: "Context deadline exceeded (timeout)", + //nolint:revive // Test error string matches real wazero error format + input: errors.New(`context deadline exceeded (recovered by wazero) +wasm stack trace: + ...`), + expectedCategory: CategoryTimeout, + expectedMessage: "Policy execution timeout exceeded", + expectedHint: "Consider optimizing network calls, reducing data processing, or increasing the timeout", + }, + { + name: "Out of memory error", + input: errors.New(`out of memory (recovered by wazero) +wasm stack trace: + ...`), + expectedCategory: CategoryMemory, + expectedMessage: "Policy exceeded memory limits", + expectedHint: "Review data structures and avoid loading large files entirely into memory", + }, + { + name: "Runtime error - index out of range", + input: errors.New(`runtime error: index out of range (recovered by wazero) +wasm stack trace: + ...`), + expectedCategory: CategoryPanic, + expectedMessage: "Runtime error in policy: index out of range", + expectedHint: "Enable debug logging with --debug to see detailed stack traces", + }, + { + name: "Runtime error - nil pointer dereference", + input: errors.New(`runtime error: invalid memory address or nil pointer dereference (recovered by wazero) +wasm stack trace: + ...`), + expectedCategory: CategoryPanic, + expectedMessage: "Runtime error in policy: invalid memory address or nil pointer dereference", + expectedHint: "Enable debug logging with --debug to see detailed stack traces", + }, + { + name: "Unknown error with clean message", + input: errors.New(`failed to parse JSON input (recovered by wazero) +wasm stack trace: + ...`), + expectedCategory: CategoryUnknown, + expectedMessage: "failed to parse JSON input", + expectedHint: "Enable debug logging with --debug for more details", + }, + { + name: "Generic error without recognizable pattern", + input: errors.New(`something went wrong +wasm stack trace: + ...`), + expectedCategory: CategoryUnknown, + expectedMessage: "something went wrong", + expectedHint: "Enable debug logging with --debug for more details", + }, + { + name: "Error with only stack trace (no clean message)", + input: errors.New(`wasm stack trace: + extism:host/env.http_request(i64,i64) i64 + main.Execute() i32`), + expectedCategory: CategoryUnknown, + expectedMessage: "Policy execution failed", + expectedHint: "Enable debug logging with --debug for detailed error information", + }, + { + name: "Nil error", + input: nil, + expectedCategory: "", + expectedMessage: "", + expectedHint: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parsed := parseWasmError(tt.input) + + if tt.input == nil { + assert.Nil(t, parsed) + return + } + + require.NotNil(t, parsed) + assert.Equal(t, tt.expectedCategory, parsed.Category) + assert.Contains(t, parsed.UserMessage, tt.expectedMessage) + assert.Contains(t, parsed.Hint, tt.expectedHint) + assert.Equal(t, tt.input, parsed.OriginalErr) + }) + } +} + +func TestExtractHostname(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "HTTPS URL with path", + input: "https://httpbin.org/json", + expected: "httpbin.org", + }, + { + name: "HTTP URL with path", + input: "http://example.com/api/data", + expected: "example.com", + }, + { + name: "URL with port", + input: "https://localhost:8080/test", + expected: "localhost", + }, + { + name: "URL with subdomain", + input: "https://api.github.com/repos", + expected: "api.github.com", + }, + { + name: "Hostname only", + input: "example.com", + expected: "example.com", + }, + { + name: "URL without protocol", + input: "httpbin.org/json", + expected: "httpbin.org", + }, + { + name: "Complex URL with auth and port", + input: "https://user:pass@example.com:8080/path?query=1", + expected: "user", // Note: extractHostname stops at first : which is the auth separator + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractHostname(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestPolicyErrorError(t *testing.T) { + tests := []struct { + name string + err *PolicyError + expected string + }{ + { + name: "Error with hint", + err: &PolicyError{ + Category: CategoryHTTPForbidden, + UserMessage: "HTTP request blocked", + Hint: "Add the hostname using --allowed-hostnames", + OriginalErr: errors.New("original"), + }, + expected: "HTTP request blocked\n\nHint: Add the hostname using --allowed-hostnames", + }, + { + name: "Error without hint", + err: &PolicyError{ + Category: CategoryUnknown, + UserMessage: "Something went wrong", + Hint: "", + OriginalErr: errors.New("original"), + }, + expected: "Something went wrong", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.err.Error() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestErrorPatterns(t *testing.T) { + // Test that all error patterns compile and match expected inputs + for i, ep := range errorPatterns { + t.Run(string(ep.category), func(t *testing.T) { + // Ensure pattern compiles (should not panic) + require.NotNil(t, ep.pattern, "Pattern %d should not be nil", i) + + // Ensure extract function exists + require.NotNil(t, ep.extract, "Extract function %d should not be nil", i) + + // Test that extract function doesn't panic with valid matches + switch ep.category { + case CategoryHTTPForbidden: + if strings.Contains(ep.pattern.String(), "HTTP request") { + matches := []string{"full", "https://example.com/test"} + message, hint := ep.extract(matches) + assert.NotEmpty(t, message) + assert.NotEmpty(t, hint) + } + case CategoryTimeout: + matches := []string{"full"} + message, hint := ep.extract(matches) + assert.NotEmpty(t, message) + assert.NotEmpty(t, hint) + case CategoryMemory: + matches := []string{"full"} + message, hint := ep.extract(matches) + assert.NotEmpty(t, message) + assert.NotEmpty(t, hint) + case CategoryPanic: + matches := []string{"full", "index out of range"} + message, hint := ep.extract(matches) + assert.NotEmpty(t, message) + assert.NotEmpty(t, hint) + } + }) + } +} + +func TestRealWorldErrors(t *testing.T) { + // Test actual error messages seen from Extism/wazero + realWorldErrors := []struct { + name string + err string + expectedCategory ErrorCategory + }{ + { + name: "Extism HTTP forbidden", + err: `HTTP request to 'https://registry.npmjs.org/lodash' is not allowed (recovered by wazero) +wasm stack trace: + extism:host/env.http_request(i64,i64) i64 + main.Execute() i32 + 0x2a26b: /Users/user/go/pkg/mod/github.com/extism/go-pdk@v1.1.3/extism_pdk.go:394:34 (inlined) + /Users/user/go/pkg/mod/github.com/extism/go-pdk@v1.1.3/internal/memory/memory.go:234:37 (inlined) + /Users/user/go/pkg/mod/github.com/extism/go-pdk@v1.1.3/env.go:271:12`, + expectedCategory: CategoryHTTPForbidden, + }, + { + name: "Context timeout", + err: `context deadline exceeded (recovered by wazero) +wasm stack trace: + main.Execute() i32`, + expectedCategory: CategoryTimeout, + }, + { + name: "Memory limit", + err: `out of memory (recovered by wazero) +wasm stack trace: + malloc(i32) i32 + main.Execute() i32`, + expectedCategory: CategoryMemory, + }, + } + + for _, tt := range realWorldErrors { + t.Run(tt.name, func(t *testing.T) { + parsed := parseWasmError(errors.New(tt.err)) + require.NotNil(t, parsed) + assert.Equal(t, tt.expectedCategory, parsed.Category) + assert.NotEmpty(t, parsed.UserMessage) + assert.NotEmpty(t, parsed.Hint) + }) + } +} diff --git a/pkg/policies/policies.go b/pkg/policies/policies.go index 3b5488e90..501759323 100644 --- a/pkg/policies/policies.go +++ b/pkg/policies/policies.go @@ -30,6 +30,7 @@ import ( intoto "github.com/in-toto/attestation/go/v1" "github.com/rs/zerolog" "github.com/sigstore/cosign/v2/pkg/blob" + "google.golang.org/grpc" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" @@ -37,6 +38,7 @@ import ( v12 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" "github.com/chainloop-dev/chainloop/pkg/policies/engine" "github.com/chainloop-dev/chainloop/pkg/policies/engine/rego" + "github.com/chainloop-dev/chainloop/pkg/policies/engine/wasm" ) type PolicyError struct { @@ -64,6 +66,7 @@ type PolicyVerifier struct { policies *v1.Policies logger *zerolog.Logger client v13.AttestationServiceClient + grpcConn *grpc.ClientConn allowedHostnames []string includeRawData bool enablePrint bool @@ -75,6 +78,7 @@ type PolicyVerifierOptions struct { AllowedHostnames []string IncludeRawData bool EnablePrint bool + GRPCConn *grpc.ClientConn } type PolicyVerifierOption func(*PolicyVerifierOptions) @@ -97,6 +101,12 @@ func WithEnablePrint(enable bool) PolicyVerifierOption { } } +func WithGRPCConn(conn *grpc.ClientConn) PolicyVerifierOption { + return func(o *PolicyVerifierOptions) { + o.GRPCConn = conn + } +} + func NewPolicyVerifier(policies *v1.Policies, client v13.AttestationServiceClient, logger *zerolog.Logger, opts ...PolicyVerifierOption) *PolicyVerifier { options := &PolicyVerifierOptions{} for _, opt := range opts { @@ -107,6 +117,7 @@ func NewPolicyVerifier(policies *v1.Policies, client v13.AttestationServiceClien policies: policies, client: client, logger: logger, + grpcConn: options.GRPCConn, allowedHostnames: options.AllowedHostnames, includeRawData: options.IncludeRawData, enablePrint: options.EnablePrint, @@ -347,26 +358,56 @@ func (pv *PolicyVerifier) VerifyStatement(ctx context.Context, statement *intoto } func (pv *PolicyVerifier) executeScript(ctx context.Context, script *engine.Policy, material []byte, args map[string]string) (*engine.EvaluationResult, error) { - engineOpts := []rego.EngineOption{} + // Detect policy type + policyType := engine.DetectPolicyType(script.Source) + + pv.logger.Debug().Str("policy", script.Name).Str("type", string(policyType)).Msg("executing policy") + + // Create appropriate engine + var policyEngine engine.PolicyEngine + var err error + + // Build engine options that apply to both engine types + var opts []engine.Option if pv.allowedHostnames != nil { pv.logger.Debug().Strs("hostnames", pv.allowedHostnames).Msg("adding additional allowed hostnames") - engineOpts = append(engineOpts, rego.WithAllowedNetworkDomains(pv.allowedHostnames...)) + opts = append(opts, engine.WithAllowedHostnames(pv.allowedHostnames...)) } if pv.includeRawData { - engineOpts = append(engineOpts, rego.WithIncludeRawData(true)) + opts = append(opts, engine.WithIncludeRawData(true)) } if pv.enablePrint { - engineOpts = append(engineOpts, rego.WithEnablePrint(true)) + opts = append(opts, engine.WithEnablePrint(true)) + } + + if pv.logger != nil { + opts = append(opts, engine.WithLogger(pv.logger)) + } + + if pv.grpcConn != nil { + opts = append(opts, engine.WithGRPCConn(pv.grpcConn)) + } + + switch policyType { + case engine.PolicyTypeRego: + policyEngine = rego.NewEngine(opts...) + case engine.PolicyTypeWASM: + policyEngine = wasm.NewEngine(opts...) + + default: + return nil, fmt.Errorf("unknown policy type: %s", policyType) } - // verify the policy - ng := rego.NewEngine(engineOpts...) - res, err := ng.Verify(ctx, script, material, getInputArguments(args)) + // Convert args from map[string]string to map[string]any + argsAny := getInputArguments(args) + + // Execute using the selected engine + res, err := policyEngine.Verify(ctx, script, material, argsAny) if err != nil { - return nil, fmt.Errorf("failed to execute policy : %w", err) + return nil, fmt.Errorf("failed to execute policy: %w", err) } return res, nil @@ -620,10 +661,12 @@ func LoadPolicyScriptsFromSpec(policy *v1.Policy, kind v1.CraftingSchema_Materia return nil, fmt.Errorf("failed to load policy script: %w", err) } - // Inject boilerplate if needed - script, err = rego.InjectBoilerplate(script, policy.GetMetadata().GetName()) - if err != nil { - return nil, fmt.Errorf("failed to inject boilerplate: %w", err) + // Inject boilerplate only for Rego policies, not WASM + if engine.DetectPolicyType(script) == engine.PolicyTypeRego { + script, err = rego.InjectBoilerplate(script, policy.GetMetadata().GetName()) + if err != nil { + return nil, fmt.Errorf("failed to inject boilerplate: %w", err) + } } scripts = append(scripts, &engine.Policy{Source: script, Name: policy.GetMetadata().GetName()}) @@ -637,10 +680,12 @@ func LoadPolicyScriptsFromSpec(policy *v1.Policy, kind v1.CraftingSchema_Materia return nil, fmt.Errorf("failed to load policy script: %w", err) } - // Inject boilerplate if needed - script, err = rego.InjectBoilerplate(script, policy.GetMetadata().GetName()) - if err != nil { - return nil, fmt.Errorf("failed to inject boilerplate: %w", err) + // Inject boilerplate only for Rego policies, not WASM + if engine.DetectPolicyType(script) == engine.PolicyTypeRego { + script, err = rego.InjectBoilerplate(script, policy.GetMetadata().GetName()) + if err != nil { + return nil, fmt.Errorf("failed to inject boilerplate: %w", err) + } } scripts = append(scripts, &engine.Policy{Source: script, Name: policy.GetMetadata().GetName()}) @@ -651,6 +696,26 @@ func LoadPolicyScriptsFromSpec(policy *v1.Policy, kind v1.CraftingSchema_Materia return scripts, nil } +// decodeIfBase64Wasm checks if content is base64-encoded WASM and decodes it. +// Returns the original content if it's not base64 or not WASM after decoding. +func decodeIfBase64Wasm(content []byte) []byte { + // Try to decode as base64 + decoded, err := base64.StdEncoding.DecodeString(string(content)) + if err != nil { + // Not base64, return original content + return content + } + + // Check if decoded content is WASM + if engine.DetectPolicyType(decoded) == engine.PolicyTypeWASM { + // It's base64-encoded WASM, return decoded bytes + return decoded + } + + // Decoded but not WASM, return original content + return content +} + func loadPolicyScript(spec *v1.PolicySpecV2, basePath string) ([]byte, error) { var content []byte var err error @@ -668,6 +733,9 @@ func loadPolicyScript(spec *v1.PolicySpecV2, basePath string) ([]byte, error) { return nil, fmt.Errorf("policy spec is empty") } + // Decode base64 if this is a base64-encoded WASM policy + content = decodeIfBase64Wasm(content) + return content, nil } @@ -689,6 +757,9 @@ func loadLegacyPolicyScript(spec *v1.PolicySpec, basePath string) ([]byte, error return nil, fmt.Errorf("policy spec is empty") } + // Decode base64 if this is a base64-encoded WASM policy + content = decodeIfBase64Wasm(content) + return content, nil } diff --git a/pkg/policies/wasm_integration_test.go b/pkg/policies/wasm_integration_test.go new file mode 100644 index 000000000..faf2a4095 --- /dev/null +++ b/pkg/policies/wasm_integration_test.go @@ -0,0 +1,55 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policies + +import ( + "testing" + + "github.com/chainloop-dev/chainloop/pkg/policies/engine" + "github.com/stretchr/testify/assert" +) + +// TestWASMPolicyTypeDetection verifies that WASM policies are detected correctly +func TestWASMPolicyTypeDetection(t *testing.T) { + tests := []struct { + name string + source []byte + expected engine.PolicyType + }{ + { + name: "Rego policy source", + source: []byte("package chainloop\n\nresult = {\"violations\": []}"), + expected: engine.PolicyTypeRego, + }, + { + name: "WASM policy source", + source: []byte{0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00}, + expected: engine.PolicyTypeWASM, + }, + { + name: "Empty source defaults to Rego", + source: []byte{}, + expected: engine.PolicyTypeRego, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + detected := engine.DetectPolicyType(tt.source) + assert.Equal(t, tt.expected, detected) + }) + } +}