Skip to content

Commit ba60911

Browse files
authored
chore(sdks): Bootstrap Go and Javascript policy sdks (#2591)
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>
1 parent 3fab487 commit ba60911

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

108 files changed

+7967
-56
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ bin/
4141
# Go releaser
4242
dist/
4343

44+
# Node.js
45+
**/node_modules/
46+
**/dist/
47+
4448
# Dev configuration associated with authentication
4549
*/*/*/secret.yaml
4650

app/cli/internal/policydevel/eval.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"github.com/chainloop-dev/chainloop/pkg/casclient"
2525
"github.com/chainloop-dev/chainloop/pkg/policies"
2626
"github.com/rs/zerolog"
27+
"google.golang.org/grpc"
2728

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

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

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

106-
func verifyMaterial(pol *v1.Policies, material *v12.Attestation_Material, materialPath string, debug bool, allowedHostnames []string, attestationClient controlplanev1.AttestationServiceClient, logger *zerolog.Logger) (*EvalSummary, error) {
108+
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) {
107109
var opts []policies.PolicyVerifierOption
108110
if len(allowedHostnames) > 0 {
109111
opts = append(opts, policies.WithAllowedHostnames(allowedHostnames...))
110112
}
111113

112114
opts = append(opts, policies.WithIncludeRawData(debug))
113115
opts = append(opts, policies.WithEnablePrint(enablePrint))
116+
opts = append(opts, policies.WithGRPCConn(grpcConn))
114117

115118
v := policies.NewPolicyVerifier(pol, attestationClient, logger, opts...)
116119
policyEvs, err := v.VerifyMaterial(context.Background(), material, materialPath)

app/cli/pkg/action/policy_develop_eval.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2025 The Chainloop Authors.
2+
// Copyright 2024-2025 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -58,6 +58,7 @@ func (action *PolicyEval) Run() (*policydevel.EvalSummary, error) {
5858
AllowedHostnames: action.opts.AllowedHostnames,
5959
Debug: action.opts.Debug,
6060
AttestationClient: attClient,
61+
ControlPlaneConn: action.CPConnection,
6162
}
6263

6364
// Evaluate policy
Lines changed: 60 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2025 The Chainloop Authors.
2+
// Copyright 2024-2025 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -23,59 +23,70 @@ import (
2323
"google.golang.org/grpc"
2424
)
2525

26-
// CreateDiscoverHostFunction creates an Extism host function for the discover builtin
27-
// This allows WASM policies to call chainloop_discover(digest, kind) and get artifact graph data
28-
func CreateDiscoverHostFunction(conn *grpc.ClientConn) extism.HostFunction {
26+
// CreateDiscoverHostFunctions creates Extism host functions for the discover builtin.
27+
// Returns two host functions - one for each supported namespace:
28+
// 1. "env" namespace for Go (TinyGo) policies
29+
// 2. "extism:host/user" namespace for JavaScript policies
30+
func CreateDiscoverHostFunctions(conn *grpc.ClientConn) []extism.HostFunction {
2931
discoverSvc := NewDiscoverService(conn)
3032

31-
return extism.NewHostFunctionWithStack(
32-
"chainloop_discover",
33-
func(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64) {
34-
// Read digest from WASM memory
35-
digestOffset := stack[0]
36-
digest, err := plugin.ReadString(digestOffset)
37-
if err != nil {
38-
// Return 0 to signal error
39-
stack[0] = 0
40-
return
41-
}
33+
// Shared implementation for the host function
34+
impl := func(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64) {
35+
// Read digest from WASM memory
36+
digestOffset := stack[0]
37+
digest, err := plugin.ReadString(digestOffset)
38+
if err != nil {
39+
// Return 0 to signal error
40+
stack[0] = 0
41+
return
42+
}
4243

43-
// Read kind from WASM memory (if provided)
44-
var kind string
45-
if len(stack) > 1 && stack[1] != 0 {
46-
kindOffset := stack[1]
47-
kind, _ = plugin.ReadString(kindOffset)
48-
}
44+
// Read kind from WASM memory (if provided)
45+
var kind string
46+
if len(stack) > 1 && stack[1] != 0 {
47+
kindOffset := stack[1]
48+
kind, _ = plugin.ReadString(kindOffset)
49+
}
4950

50-
// Call shared discover service
51-
resp, err := discoverSvc.Discover(ctx, digest, kind)
52-
if err != nil {
53-
// Return 0 to signal error
54-
stack[0] = 0
55-
return
56-
}
51+
// Call shared discover service
52+
resp, err := discoverSvc.Discover(ctx, digest, kind)
53+
if err != nil || resp == nil {
54+
// Return 0 to signal error (no connection or error)
55+
stack[0] = 0
56+
return
57+
}
5758

58-
// Serialize response to JSON
59-
jsonData, err := json.Marshal(resp.Result)
60-
if err != nil {
61-
// Return 0 to signal error
62-
stack[0] = 0
63-
return
64-
}
59+
// Serialize response to JSON
60+
jsonData, err := json.Marshal(resp.Result)
61+
if err != nil {
62+
// Return 0 to signal error
63+
stack[0] = 0
64+
return
65+
}
6566

66-
// Write JSON to WASM memory and return offset
67-
offset, err := plugin.WriteBytes(jsonData)
68-
if err != nil {
69-
// Return 0 to signal error
70-
stack[0] = 0
71-
return
72-
}
67+
// Write JSON string to WASM memory and return offset
68+
offset, err := plugin.WriteString(string(jsonData))
69+
if err != nil {
70+
// Return 0 to signal error
71+
stack[0] = 0
72+
return
73+
}
7374

74-
stack[0] = offset
75-
},
76-
// inputs: digest offset, kind offset
77-
[]extism.ValueType{extism.ValueTypeI64, extism.ValueTypeI64},
78-
// output: json result offset or 0 on error
79-
[]extism.ValueType{extism.ValueTypeI64},
80-
)
75+
stack[0] = offset
76+
}
77+
78+
// inputs: digest offset, kind offset
79+
inputs := []extism.ValueType{extism.ValueTypeI64, extism.ValueTypeI64}
80+
// output: json result offset or 0 on error
81+
outputs := []extism.ValueType{extism.ValueTypeI64}
82+
83+
// Create host function for "env" namespace (Go/TinyGo policies)
84+
envFunc := extism.NewHostFunctionWithStack("chainloop_discover", impl, inputs, outputs)
85+
envFunc.SetNamespace("env")
86+
87+
// Create host function for "extism:host/user" namespace (JavaScript policies)
88+
jsFunc := extism.NewHostFunctionWithStack("chainloop_discover", impl, inputs, outputs)
89+
jsFunc.SetNamespace("extism:host/user")
90+
91+
return []extism.HostFunction{envFunc, jsFunc}
8192
}

pkg/policies/engine/wasm/engine.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ func (e *Engine) Verify(ctx context.Context, policy *engine.Policy, input []byte
104104
},
105105
Config: configMap,
106106
AllowedHosts: e.AllowedHostnames,
107+
Timeout: uint64(e.executionTimeout.Milliseconds()),
107108
}
108109

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

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

124123
// Create plugin with host functions
125124
plugin, err := extism.NewPlugin(ctx, manifest, config, hostFunctions)

pkg/policies/engine/wasm/engine_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,14 @@ package wasm
1717

1818
import (
1919
"context"
20+
"os"
21+
"path/filepath"
2022
"testing"
2123
"time"
2224

2325
"github.com/chainloop-dev/chainloop/pkg/policies/engine"
2426
"github.com/stretchr/testify/assert"
27+
"github.com/stretchr/testify/require"
2528
)
2629

2730
func TestNewEngine(t *testing.T) {
@@ -109,3 +112,76 @@ func TestMatchesEvaluation(t *testing.T) {
109112
assert.NoError(t, err)
110113
assert.True(t, matches)
111114
}
115+
116+
// TestSimpleWASMExecution verifies that basic WASM policy execution works
117+
func TestSimpleWASMExecution(t *testing.T) {
118+
// Load a simple test WASM policy
119+
wasmPath := filepath.Join("testdata", "simple_test_policy.wasm")
120+
wasmBytes, err := os.ReadFile(wasmPath)
121+
require.NoError(t, err, "Failed to load simple test WASM policy")
122+
123+
eng := NewEngine()
124+
ctx := context.Background()
125+
126+
policy := &engine.Policy{
127+
Name: "simple-test",
128+
Source: wasmBytes,
129+
}
130+
input := []byte(`{}`)
131+
132+
result, err := eng.Verify(ctx, policy, input, nil)
133+
require.NoError(t, err, "Policy execution should not error")
134+
require.NotNil(t, result)
135+
136+
// Should have one violation: "test violation"
137+
assert.Len(t, result.Violations, 1)
138+
assert.Equal(t, "test violation", result.Violations[0].Violation)
139+
}
140+
141+
// TestFilesystemIsolation verifies that WASM policies CANNOT access the host filesystem
142+
//
143+
// IMPORTANT SECURITY VERIFICATION:
144+
// This test confirms that the Extism runtime provides filesystem isolation by default
145+
// when EnableWasi is true. Even without explicit AllowedPaths configuration, WASM policies
146+
// are sandboxed and cannot access sensitive host filesystem paths like:
147+
// - /etc/passwd, /etc/hosts (system files)
148+
// - / (root directory)
149+
// - . (current working directory)
150+
//
151+
// The test policy attempts to stat() these paths and reports violations if successful.
152+
// A passing test (no violations) means filesystem isolation is working correctly.
153+
func TestFilesystemIsolation(t *testing.T) {
154+
// Load the compiled test WASM policy that attempts filesystem access
155+
wasmPath := filepath.Join("testdata", "filesystem_test_policy.wasm")
156+
wasmBytes, err := os.ReadFile(wasmPath)
157+
require.NoError(t, err, "Failed to load test WASM policy - run 'make build-test-wasm' first")
158+
159+
eng := NewEngine()
160+
ctx := context.Background()
161+
162+
policy := &engine.Policy{
163+
Name: "filesystem-security-test",
164+
Source: wasmBytes,
165+
}
166+
input := []byte(`{}`) // Empty input, policy will try filesystem access
167+
168+
t.Run("verify filesystem isolation is working", func(t *testing.T) {
169+
result, err := eng.Verify(ctx, policy, input, nil)
170+
require.NoError(t, err, "Policy execution should not error")
171+
require.NotNil(t, result)
172+
173+
// Check if the policy reported any security violations
174+
// If there are violations, it means the policy was able to access host files (BAD)
175+
// If there are no violations, it means filesystem access was blocked (GOOD)
176+
if len(result.Violations) > 0 {
177+
t.Logf("SECURITY WARNING: Policy accessed host filesystem!")
178+
for _, violation := range result.Violations {
179+
t.Logf(" - %s", violation.Violation)
180+
}
181+
require.FailNow(t, "SECURITY ISSUE: WASM policy was able to access host filesystem without isolation")
182+
} else {
183+
t.Log("Filesystem isolation is working correctly - policy was blocked from accessing host files")
184+
t.Log("Verified: /etc/passwd, /etc/hosts, current directory, and root directory are all inaccessible")
185+
}
186+
})
187+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
//go:build tinygo.wasm
2+
3+
// Copyright 2024-2025 The Chainloop Authors.
4+
//
5+
// Licensed under the Apache License, Version 2.0 (the "License");
6+
// you may not use this file except in compliance with the License.
7+
// You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing, software
12+
// distributed under the License is distributed on an "AS IS" BASIS,
13+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
// See the License for the specific language governing permissions and
15+
// limitations under the License.
16+
17+
package main
18+
19+
import (
20+
"encoding/json"
21+
"os"
22+
23+
"github.com/extism/go-pdk"
24+
)
25+
26+
type Result struct {
27+
Violations []string `json:"violations"`
28+
Skipped bool `json:"skipped"`
29+
}
30+
31+
// Execute attempts to access various host filesystem paths
32+
//
33+
//export Execute
34+
func Execute() int32 {
35+
result := Result{Violations: []string{}, Skipped: false}
36+
37+
// Attempt to stat /etc/passwd (common on Unix systems)
38+
// Using Stat instead of ReadFile to avoid potential memory issues
39+
_, err1 := os.Stat("/etc/passwd")
40+
if err1 == nil {
41+
result.Violations = append(result.Violations, "SECURITY VIOLATION: Successfully accessed /etc/passwd")
42+
}
43+
44+
// Attempt to stat /etc/hosts
45+
_, err2 := os.Stat("/etc/hosts")
46+
if err2 == nil {
47+
result.Violations = append(result.Violations, "SECURITY VIOLATION: Successfully accessed /etc/hosts")
48+
}
49+
50+
// Attempt to stat current directory
51+
_, err3 := os.Stat(".")
52+
if err3 == nil {
53+
result.Violations = append(result.Violations, "SECURITY VIOLATION: Successfully accessed current directory")
54+
}
55+
56+
// Attempt to stat root directory
57+
_, err4 := os.Stat("/")
58+
if err4 == nil {
59+
result.Violations = append(result.Violations, "SECURITY VIOLATION: Successfully accessed root directory")
60+
}
61+
62+
// Output result
63+
output, err := json.Marshal(result)
64+
if err != nil {
65+
return 1
66+
}
67+
mem := pdk.AllocateBytes(output)
68+
pdk.OutputMemory(mem)
69+
return 0
70+
}
71+
72+
func main() {}
939 KB
Binary file not shown.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module filesystem_test_policy
2+
3+
go 1.25
4+
5+
require github.com/extism/go-pdk v1.1.3
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
2+
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=

0 commit comments

Comments
 (0)