Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ bin/
# Go releaser
dist/

# Node.js
**/node_modules/
**/dist/

# Dev configuration associated with authentication
*/*/*/secret.yaml

Expand Down
7 changes: 5 additions & 2 deletions app/cli/internal/policydevel/eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/chainloop-dev/chainloop/pkg/casclient"
"github.com/chainloop-dev/chainloop/pkg/policies"
"github.com/rs/zerolog"
"google.golang.org/grpc"

v12 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials"
Expand All @@ -42,6 +43,7 @@ type EvalOptions struct {
AllowedHostnames []string
Debug bool
AttestationClient controlplanev1.AttestationServiceClient
ControlPlaneConn *grpc.ClientConn
}

type EvalResult struct {
Expand Down Expand Up @@ -75,7 +77,7 @@ func Evaluate(opts *EvalOptions, logger zerolog.Logger) (*EvalSummary, error) {
material.Annotations = opts.Annotations

// 3. Verify material against policy
summary, err := verifyMaterial(policies, material, opts.MaterialPath, opts.Debug, opts.AllowedHostnames, opts.AttestationClient, &logger)
summary, err := verifyMaterial(policies, material, opts.MaterialPath, opts.Debug, opts.AllowedHostnames, opts.AttestationClient, opts.ControlPlaneConn, &logger)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -103,14 +105,15 @@ func createPolicies(policyPath string, inputs map[string]string) (*v1.Policies,
}, nil
}

func verifyMaterial(pol *v1.Policies, material *v12.Attestation_Material, materialPath string, debug bool, allowedHostnames []string, attestationClient controlplanev1.AttestationServiceClient, logger *zerolog.Logger) (*EvalSummary, error) {
func verifyMaterial(pol *v1.Policies, material *v12.Attestation_Material, materialPath string, debug bool, allowedHostnames []string, attestationClient controlplanev1.AttestationServiceClient, grpcConn *grpc.ClientConn, logger *zerolog.Logger) (*EvalSummary, error) {
var opts []policies.PolicyVerifierOption
if len(allowedHostnames) > 0 {
opts = append(opts, policies.WithAllowedHostnames(allowedHostnames...))
}

opts = append(opts, policies.WithIncludeRawData(debug))
opts = append(opts, policies.WithEnablePrint(enablePrint))
opts = append(opts, policies.WithGRPCConn(grpcConn))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if the Rego engine can also leverage this option. We can review it in a future change.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oki!


v := policies.NewPolicyVerifier(pol, attestationClient, logger, opts...)
policyEvs, err := v.VerifyMaterial(context.Background(), material, materialPath)
Expand Down
3 changes: 2 additions & 1 deletion app/cli/pkg/action/policy_develop_eval.go
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -58,6 +58,7 @@ func (action *PolicyEval) Run() (*policydevel.EvalSummary, error) {
AllowedHostnames: action.opts.AllowedHostnames,
Debug: action.opts.Debug,
AttestationClient: attClient,
ControlPlaneConn: action.CPConnection,
}

// Evaluate policy
Expand Down
109 changes: 60 additions & 49 deletions pkg/policies/engine/builtins/wasm.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -23,59 +23,70 @@ import (
"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 {
// CreateDiscoverHostFunctions creates Extism host functions for the discover builtin.
// Returns two host functions - one for each supported namespace:
// 1. "env" namespace for Go (TinyGo) policies
// 2. "extism:host/user" namespace for JavaScript policies
func CreateDiscoverHostFunctions(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
}
// Shared implementation for the host function
impl := 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)
}
// 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
}
// Call shared discover service
resp, err := discoverSvc.Discover(ctx, digest, kind)
if err != nil || resp == nil {
// Return 0 to signal error (no connection or 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
}
// 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
}
// Write JSON string to WASM memory and return offset
offset, err := plugin.WriteString(string(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},
)
stack[0] = offset
}

// inputs: digest offset, kind offset
inputs := []extism.ValueType{extism.ValueTypeI64, extism.ValueTypeI64}
// output: json result offset or 0 on error
outputs := []extism.ValueType{extism.ValueTypeI64}

// Create host function for "env" namespace (Go/TinyGo policies)
envFunc := extism.NewHostFunctionWithStack("chainloop_discover", impl, inputs, outputs)
envFunc.SetNamespace("env")

// Create host function for "extism:host/user" namespace (JavaScript policies)
jsFunc := extism.NewHostFunctionWithStack("chainloop_discover", impl, inputs, outputs)
jsFunc.SetNamespace("extism:host/user")

return []extism.HostFunction{envFunc, jsFunc}
}
7 changes: 3 additions & 4 deletions pkg/policies/engine/wasm/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ func (e *Engine) Verify(ctx context.Context, policy *engine.Policy, input []byte
},
Config: configMap,
AllowedHosts: e.AllowedHostnames,
Timeout: uint64(e.executionTimeout.Milliseconds()),
}

// Log allowed hosts configuration
Expand All @@ -116,10 +117,8 @@ func (e *Engine) Verify(ctx context.Context, policy *engine.Policy, input []byte
}

// Register host functions
var hostFunctions []extism.HostFunction
if e.ControlPlaneConnection != nil {
hostFunctions = append(hostFunctions, builtins.CreateDiscoverHostFunction(e.ControlPlaneConnection))
}
// Registers in both "env" (for Go/TinyGo) and "extism:host/user" (for JavaScript) namespaces
hostFunctions := builtins.CreateDiscoverHostFunctions(e.ControlPlaneConnection)

// Create plugin with host functions
plugin, err := extism.NewPlugin(ctx, manifest, config, hostFunctions)
Expand Down
76 changes: 76 additions & 0 deletions pkg/policies/engine/wasm/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@ package wasm

import (
"context"
"os"
"path/filepath"
"testing"
"time"

"github.com/chainloop-dev/chainloop/pkg/policies/engine"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestNewEngine(t *testing.T) {
Expand Down Expand Up @@ -109,3 +112,76 @@ func TestMatchesEvaluation(t *testing.T) {
assert.NoError(t, err)
assert.True(t, matches)
}

// TestSimpleWASMExecution verifies that basic WASM policy execution works
func TestSimpleWASMExecution(t *testing.T) {
// Load a simple test WASM policy
wasmPath := filepath.Join("testdata", "simple_test_policy.wasm")
wasmBytes, err := os.ReadFile(wasmPath)
require.NoError(t, err, "Failed to load simple test WASM policy")

eng := NewEngine()
ctx := context.Background()

policy := &engine.Policy{
Name: "simple-test",
Source: wasmBytes,
}
input := []byte(`{}`)

result, err := eng.Verify(ctx, policy, input, nil)
require.NoError(t, err, "Policy execution should not error")
require.NotNil(t, result)

// Should have one violation: "test violation"
assert.Len(t, result.Violations, 1)
assert.Equal(t, "test violation", result.Violations[0].Violation)
}

// TestFilesystemIsolation verifies that WASM policies CANNOT access the host filesystem
//
// IMPORTANT SECURITY VERIFICATION:
// This test confirms that the Extism runtime provides filesystem isolation by default
// when EnableWasi is true. Even without explicit AllowedPaths configuration, WASM policies
// are sandboxed and cannot access sensitive host filesystem paths like:
// - /etc/passwd, /etc/hosts (system files)
// - / (root directory)
// - . (current working directory)
//
// The test policy attempts to stat() these paths and reports violations if successful.
// A passing test (no violations) means filesystem isolation is working correctly.
func TestFilesystemIsolation(t *testing.T) {
// Load the compiled test WASM policy that attempts filesystem access
wasmPath := filepath.Join("testdata", "filesystem_test_policy.wasm")
wasmBytes, err := os.ReadFile(wasmPath)
require.NoError(t, err, "Failed to load test WASM policy - run 'make build-test-wasm' first")

eng := NewEngine()
ctx := context.Background()

policy := &engine.Policy{
Name: "filesystem-security-test",
Source: wasmBytes,
}
input := []byte(`{}`) // Empty input, policy will try filesystem access

t.Run("verify filesystem isolation is working", func(t *testing.T) {
result, err := eng.Verify(ctx, policy, input, nil)
require.NoError(t, err, "Policy execution should not error")
require.NotNil(t, result)

// Check if the policy reported any security violations
// If there are violations, it means the policy was able to access host files (BAD)
// If there are no violations, it means filesystem access was blocked (GOOD)
if len(result.Violations) > 0 {
t.Logf("SECURITY WARNING: Policy accessed host filesystem!")
for _, violation := range result.Violations {
t.Logf(" - %s", violation.Violation)
}
require.FailNow(t, "SECURITY ISSUE: WASM policy was able to access host filesystem without isolation")
} else {
t.Log("Filesystem isolation is working correctly - policy was blocked from accessing host files")
t.Log("Verified: /etc/passwd, /etc/hosts, current directory, and root directory are all inaccessible")
}
})
}
72 changes: 72 additions & 0 deletions pkg/policies/engine/wasm/testdata/filesystem_test_policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//go:build tinygo.wasm

// 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 main

import (
"encoding/json"
"os"

"github.com/extism/go-pdk"
)

type Result struct {
Violations []string `json:"violations"`
Skipped bool `json:"skipped"`
}

// Execute attempts to access various host filesystem paths
//
//export Execute
func Execute() int32 {
result := Result{Violations: []string{}, Skipped: false}

// Attempt to stat /etc/passwd (common on Unix systems)
// Using Stat instead of ReadFile to avoid potential memory issues
_, err1 := os.Stat("/etc/passwd")
if err1 == nil {
result.Violations = append(result.Violations, "SECURITY VIOLATION: Successfully accessed /etc/passwd")
}

// Attempt to stat /etc/hosts
_, err2 := os.Stat("/etc/hosts")
if err2 == nil {
result.Violations = append(result.Violations, "SECURITY VIOLATION: Successfully accessed /etc/hosts")
}

// Attempt to stat current directory
_, err3 := os.Stat(".")
if err3 == nil {
result.Violations = append(result.Violations, "SECURITY VIOLATION: Successfully accessed current directory")
}

// Attempt to stat root directory
_, err4 := os.Stat("/")
if err4 == nil {
result.Violations = append(result.Violations, "SECURITY VIOLATION: Successfully accessed root directory")
}

// Output result
output, err := json.Marshal(result)
if err != nil {
return 1
}
mem := pdk.AllocateBytes(output)
pdk.OutputMemory(mem)
return 0
}

func main() {}
Binary file not shown.
5 changes: 5 additions & 0 deletions pkg/policies/engine/wasm/testdata/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module filesystem_test_policy

go 1.25

require github.com/extism/go-pdk v1.1.3
2 changes: 2 additions & 0 deletions pkg/policies/engine/wasm/testdata/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
Loading
Loading