diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..90ae4ed --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: {} + +jobs: + test: + name: Build guest plugins and run tests + runs-on: ubuntu-latest + env: + TINYGO_VERSION: "0.42.0" + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install TinyGo + run: | + wget -q "https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_amd64.deb" + sudo dpkg -i "tinygo_${TINYGO_VERSION}_amd64.deb" + tinygo version + + - name: Install Task + run: | + sh -c "$(curl -ssL https://taskfile.dev/install.sh)" -- -d -b /usr/local/bin + task --version + + # Every guest plugin (cipher/*, lookup/gsm) is built to plugins/*.wasm + # before the Go test suite runs, since each package's *_plugin_test.go + # (tagged "!tinygo") loads the compiled binary from ../../plugins/ via + # github.com/extism/go-sdk rather than exercising the tinygo-only + # source directly. + - name: Build all guest plugins + run: task build_all + + - name: Run tests + run: go test -v ./... diff --git a/README.md b/README.md index 9d8318e..5e21cb0 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ ## GDG Plugins +[![CI](https://github.com/esnet/gdg-plugins/actions/workflows/ci.yml/badge.svg)](https://github.com/esnet/gdg-plugins/actions/workflows/ci.yml) + This repo introduces a very alpha pattern which adds plugin support to [gdg](https://software.es.net/gdg/). There will eventually be several different types of plugins that can be incorporated. This also opens the door to @@ -9,6 +11,13 @@ Supported Types: - cipher: simple plugin that takes in a string as input and return a string as output. The plugin can define any additional configuration that will be passed on by GDG that the plugin will use. i.e. a `passphrase` for example. + - lookup: resolves a `lookup::[.]` reference (configured under `plugins.lookup` in `gdg.yml`) to a +secret value from an external store, e.g. Google Secret Manager. Like cipher plugins, the plugin's own instance is loaded once and +cached in memory for the life of the process; unlike cipher plugins, a resolved lookup *value* is also cached in memory so the +same reference is never re-resolved twice in one run. Any credentials the plugin needs (a GCP service account, for example) are +resolved host-side using the same `env:`/`file:` convention as every other plugin's config, and — where the backing API needs a +short-lived credential such as an OAuth2 access token — minted fresh by the host and handed to the guest via a host function on +every call, rather than baked into the plugin's static config. The plugins in this repo are not intended to be a full comprehensive list. Feel free to implement your own. If you would like your @@ -28,7 +37,48 @@ output to work, but your experience may vary. Once GDG 0.9.0 is out you can writ current plug-ins: - ansible-vault [wasm bin](https://raw.githubusercontent.com/esnet/gdg-plugins/refs/heads/main/plugins/cipher_ansible.wasm), [source code](https://github.com/esnet/gdg-plugins/tree/main/cipher/ansible) - aes-256-gcm [wasm bin](https://raw.githubusercontent.com/esnet/gdg-plugins/refs/heads/main/plugins/cipher_aes256_gcm.wasm), [source code](https://github.com/esnet/gdg-plugins/tree/main/cipher/aes-256-gcm) + - gsm (Google Secret Manager, lookup) [wasm bin](https://raw.githubusercontent.com/esnet/gdg-plugins/refs/heads/main/plugins/lookup_gsm.wasm), [source code](https://github.com/esnet/gdg-plugins/tree/main/lookup/gsm) + + +--- +### Building & Testing + +CI (`.github/workflows/ci.yml`) builds every guest plugin and runs the full Go test suite on every push and pull request to `main` — it installs Go (from `go.mod`'s `go` directive), TinyGo, and [Task](https://taskfile.dev/), then runs `task build_all` followed by `go test -v ./...`, so it exercises exactly the same commands described below. + +This repo uses [Task](https://taskfile.dev/) (`Taskfile.yml`) to drive [tinygo](https://tinygo.org/) builds. Install both, then: + +```sh +# build every plugin's .wasm into ./plugins/ +task build_all + +# build just the GSM lookup plugin +task lookup_gsm + +# build one of the cipher plugins +task cipher_ansible +task cipher_aes + +# build everything, then run the Go test suite (host-side tests load the +# freshly built .wasm files from ./plugins/, so build_all always runs first) +task run_tests +``` + +Each guest plugin's source lives in its own directory (`cipher/ansible`, `cipher/aes-256-gcm`, `lookup/gsm`) and is built +with the flags in `Taskfile.yml`'s `TINY_FLAGS` var (`-target wasi -no-debug -tags=purego -scheduler=none`) — tinygo's WASI target +is what makes the extism host functions (HTTP requests, config, and any custom host functions like GSM's +`get_gcp_access_token`) available to the guest. If you don't have `Task` installed, the equivalent raw command for any plugin is: + +```sh +tinygo build -o plugins/.wasm -target wasi -no-debug -tags=purego -scheduler=none /plugin.go +``` +Each plugin directory that needs host-side verification also has a `_test.go` file tagged `!tinygo` (so it's excluded from the +tinygo build itself and only compiles under the normal `go` toolchain). These load the compiled `.wasm` from `../../plugins/` +with `github.com/extism/go-sdk` and call the guest's exported functions directly — see `cipher/ansible/ansible_plugin_test.go` +for the simplest example, or `lookup/gsm/gsm_plugin_test.go` for one that also registers a fake host function (GSM's guest +calls back into the host for a GCP access token, so its tests must supply `get_gcp_access_token` themselves — real GDG supplies +the real one). One of the GSM tests reaches the real Secret Manager API (with an intentionally invalid token, to verify error +handling) and is skipped under `go test -short`. --- ### Community Managed Plugins diff --git a/Taskfile.yml b/Taskfile.yml index 3ff909c..29121e9 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -24,6 +24,10 @@ tasks: desc: "Build aes-256-gcm vault plugin " cmds: - tinygo build -o plugins/cipher_aes256_gcm.wasm {{ .TINY_FLAGS }} cipher/aes-256-gcm/plugin.go + lookup_gsm: + desc: "Build Google Secret Manager lookup plugin" + cmds: + - tinygo build -o plugins/lookup_gsm.wasm {{ .TINY_FLAGS }} lookup/gsm/plugin.go run_tests: desc: "Build ansible vault plugin " cmds: @@ -35,6 +39,7 @@ tasks: cmds: - task: cipher_ansible - task: cipher_aes + - task: lookup_gsm deps: - task: clean - task: init diff --git a/cipher/aes-256-gcm/aes256_plugin_test.go b/cipher/aes-256-gcm/aes256_plugin_test.go index 05a568b..f013c8e 100644 --- a/cipher/aes-256-gcm/aes256_plugin_test.go +++ b/cipher/aes-256-gcm/aes256_plugin_test.go @@ -5,118 +5,184 @@ package main import ( "context" + "encoding/base64" + "strings" "testing" extism "github.com/extism/go-sdk" - "github.com/matryer/is" ) const pluginPath = "../../plugins/cipher_aes256_gcm.wasm" -func TestEncodeAgeIntegration(t *testing.T) { - assert := is.New(t) - if testing.Short() { - t.Skip("Skipping integration test") - } - +// newTestPlugin builds a fresh extism.Plugin backed by the compiled +// cipher_aes256_gcm.wasm, with the given config map applied verbatim (an +// empty/nil map, or one missing "passphrase", exercises the +// no-passphrase error path). +func newTestPlugin(t *testing.T, config map[string]string) *extism.Plugin { + t.Helper() ctx := context.Background() - wasmPath := pluginPath manifest := extism.Manifest{ Wasm: []extism.Wasm{ - extism.WasmFile{ - Path: wasmPath, - }, - }, - Config: map[string]string{ - "passphrase": "integration-test-password", + extism.WasmFile{Path: pluginPath}, }, + Config: config, } - config := extism.PluginConfig{ + pluginConfig := extism.PluginConfig{ EnableWasi: true, } - plugin, err := extism.NewPlugin(ctx, manifest, config, []extism.HostFunction{}) - assert.True(err == nil) - defer plugin.Close(ctx) - - // Test multiple encryptions - inputs := []string{ - "secret1", - "secret2", + plugin, err := extism.NewPlugin(ctx, manifest, pluginConfig, []extism.HostFunction{}) + if err != nil { + t.Fatalf("Failed to initialize plugin: %v", err) } + t.Cleanup(func() { plugin.Close(context.Background()) }) + return plugin +} - outputs := []string{ - "Y5PP+Dqi4aY+Ck/IGPDachqQHp8sT+gAVh3ZjLePo4gWJzGkfWkfB6Ch22GgoDb9xhON", - "MbS/pcgIswOOwo3pZ3iai2ZMnET7WhiJduTKQxV1dyXcemfYD0MXLMSAEvy7WsGc9s8h", +// TestAES256EncryptDecryptRoundTrip verifies Encode/Decode round-trip +// correctly. It intentionally does NOT assert against a fixed expected +// ciphertext: Encode generates a random salt and nonce per call (see +// plugin.go's use of crypto/rand), so the exact ciphertext is never the +// same twice — only the round trip is stable. +func TestAES256EncryptDecryptRoundTrip(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") } - for ndx, input := range inputs { - exit, out, plugErr := plugin.Call("Encode", []byte(input)) - assert.True(plugErr == nil) - assert.True(exit == 0) - // Verify each encryption is different (has randomness) - output := string(out) - _, _ = ndx, outputs - if output != outputs[ndx] { - t.Errorf("Encode(%q) output = %q, want %q", input, output, outputs[ndx]) + plugin := newTestPlugin(t, map[string]string{"passphrase": "integration-test-password"}) + + inputs := []string{"secret1", "secret2", "a longer secret value with spaces and punctuation!"} + + for _, input := range inputs { + exit, ciphertext, err := plugin.Call("Encode", []byte(input)) + if err != nil { + t.Fatalf("Encode(%q) error = %v", input, err) + } + if exit != 0 { + t.Fatalf("Encode(%q) exit = %d, want 0 (err=%q)", input, exit, plugin.GetError()) + } + + exit, plaintext, err := plugin.Call("Decode", ciphertext) + if err != nil { + t.Fatalf("Decode(%q) error = %v", ciphertext, err) + } + if exit != 0 { + t.Fatalf("Decode(%q) exit = %d, want 0 (err=%q)", ciphertext, exit, plugin.GetError()) } + if string(plaintext) != input { + t.Errorf("round trip mismatch: got %q, want %q", plaintext, input) + } } } -func TestDecodeAgeIntegration(t *testing.T) { - assert := is.New(t) +// TestAES256Encode_ProducesDifferentCiphertextEachTime verifies the +// randomness claim the previous version of this test only asserted in a +// comment: encoding the same input twice must not produce identical +// ciphertext (a random salt and nonce are mixed into every encryption). +func TestAES256Encode_ProducesDifferentCiphertextEachTime(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test") } - ctx := context.Background() - wasmPath := pluginPath + plugin := newTestPlugin(t, map[string]string{"passphrase": "integration-test-password"}) - manifest := extism.Manifest{ - Wasm: []extism.Wasm{ - extism.WasmFile{ - Path: wasmPath, - }, - }, - Config: map[string]string{ - "passphrase": "integration-test-password", - }, + _, first, err := plugin.Call("Encode", []byte("same-input")) + if err != nil { + t.Fatalf("first Encode error = %v", err) + } + _, second, err := plugin.Call("Encode", []byte("same-input")) + if err != nil { + t.Fatalf("second Encode error = %v", err) } - config := extism.PluginConfig{ - EnableWasi: true, + if string(first) == string(second) { + t.Fatalf("expected two encryptions of the same input to differ (random salt/nonce), got identical ciphertext") + } +} + +// TestAES256Encode_MissingPassphraseReturnsError verifies a clean, +// non-panic error when no passphrase is configured. +func TestAES256Encode_MissingPassphraseReturnsError(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") } - plugin, err := extism.NewPlugin(ctx, manifest, config, []extism.HostFunction{}) - assert.True(err == nil) - defer plugin.Close(context.Background()) + plugin := newTestPlugin(t, map[string]string{}) - // Test multiple encryptions - inputs := []string{ - "Y5PP+Dqi4aY+Ck/IGPDachqQHp8sT+gAVh3ZjLePo4gWJzGkfWkfB6Ch22GgoDb9xhON", - "MbS/pcgIswOOwo3pZ3iai2ZMnET7WhiJduTKQxV1dyXcemfYD0MXLMSAEvy7WsGc9s8h", + exit, _, err := plugin.Call("Encode", []byte("secret")) + if exit == 0 { + t.Fatalf("Call(Encode) exit = 0, want non-zero when no passphrase is configured") } + if err == nil { + t.Fatalf("Call(Encode) expected an error when no passphrase is configured, got nil") + } + if !strings.Contains(err.Error(), "passphrase") { + t.Fatalf("expected error mentioning a missing passphrase, got %q", err) + } +} - outputs := []string{ - "secret1", - "secret2", +// TestAES256Decode_MissingPassphraseReturnsError mirrors the Encode case +// for Decode. +func TestAES256Decode_MissingPassphraseReturnsError(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") } - for ndx, input := range inputs { - exit, out, plugErr := plugin.Call("Decode", []byte(input)) - if plugErr != nil { - t.Fatalf("Decode(%q) error = %v", input, plugErr) - } - assert.True(exit == 0) + plugin := newTestPlugin(t, map[string]string{}) - // Verify each encryption is different (has randomness) - output := string(out) - _, _ = ndx, outputs - if output != outputs[ndx] { - t.Errorf("Decode(%q) output = %q, want %q", input, output, outputs[ndx]) - } + exit, _, err := plugin.Call("Decode", []byte(base64.StdEncoding.EncodeToString([]byte("irrelevant-because-passphrase-missing")))) + if exit == 0 { + t.Fatalf("Call(Decode) exit = 0, want non-zero when no passphrase is configured") + } + if err == nil { + t.Fatalf("Call(Decode) expected an error when no passphrase is configured, got nil") + } + if !strings.Contains(err.Error(), "passphrase") { + t.Fatalf("expected error mentioning a missing passphrase, got %q", err) + } +} + +// TestAES256Decode_InvalidBase64ReturnsError verifies that input which +// isn't valid base64 at all is rejected cleanly by the base64-decode step, +// before any cryptography is attempted. +func TestAES256Decode_InvalidBase64ReturnsError(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + plugin := newTestPlugin(t, map[string]string{"passphrase": "integration-test-password"}) + + exit, _, err := plugin.Call("Decode", []byte("not-valid-base64!!!")) + if exit == 0 { + t.Fatalf("Call(Decode) exit = 0, want non-zero for invalid base64 input") + } + if err == nil { + t.Fatalf("expected a non-empty error for invalid base64 input") + } +} + +// TestAES256Decode_TooShortDataReturnsError verifies that base64-valid +// input which decodes to fewer bytes than saltSize+nonceSize is rejected +// as "invalid encrypted data" rather than panicking on a short slice. +func TestAES256Decode_TooShortDataReturnsError(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + plugin := newTestPlugin(t, map[string]string{"passphrase": "integration-test-password"}) + + tooShort := base64.StdEncoding.EncodeToString([]byte("short")) + exit, _, err := plugin.Call("Decode", []byte(tooShort)) + if exit == 0 { + t.Fatalf("Call(Decode) exit = 0, want non-zero for too-short encrypted data") + } + if err == nil { + t.Fatalf("Call(Decode) expected an error for too-short encrypted data, got nil") + } + if !strings.Contains(err.Error(), "invalid encrypted data") { + t.Fatalf("expected error mentioning invalid encrypted data, got %q", err) } } diff --git a/cipher/ansible/ansible_plugin_test.go b/cipher/ansible/ansible_plugin_test.go index 9360a78..df02dce 100644 --- a/cipher/ansible/ansible_plugin_test.go +++ b/cipher/ansible/ansible_plugin_test.go @@ -5,6 +5,7 @@ package main import ( "context" + "strings" "testing" extism "github.com/extism/go-sdk" @@ -12,118 +13,152 @@ import ( const pluginPath = "../../plugins/cipher_ansible.wasm" -func TestEncodeAnsibleIntegration(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - +// newTestPlugin builds a fresh extism.Plugin backed by the compiled +// cipher_ansible.wasm, with the given config map applied verbatim (an +// empty/nil map, or one missing "vault_password", exercises the +// no-password error path). +func newTestPlugin(t *testing.T, config map[string]string) *extism.Plugin { + t.Helper() ctx := context.Background() - wasmPath := pluginPath manifest := extism.Manifest{ Wasm: []extism.Wasm{ - extism.WasmFile{ - Path: wasmPath, - }, - }, - Config: map[string]string{ - "vault_password": "integration-test-password", + extism.WasmFile{Path: pluginPath}, }, + Config: config, } - config := extism.PluginConfig{ + pluginConfig := extism.PluginConfig{ EnableWasi: true, } - plugin, err := extism.NewPlugin(ctx, manifest, config, []extism.HostFunction{}) + plugin, err := extism.NewPlugin(ctx, manifest, pluginConfig, []extism.HostFunction{}) if err != nil { t.Fatalf("Failed to initialize plugin: %v", err) } - defer plugin.Close(context.Background()) + t.Cleanup(func() { plugin.Close(context.Background()) }) + return plugin +} - // Test multiple encryptions - inputs := []string{ - "secret1", - "secret2", +// TestAnsibleEncryptDecryptRoundTrip verifies Encode/Decode round-trip +// correctly. It intentionally does NOT assert against a fixed expected +// ciphertext: ansible-vault-go's Encrypt generates a random salt per call +// (see sosedoff/ansible-vault-go's generateRandomBytes), so the exact +// ciphertext is never the same twice — only the round trip is stable. +func TestAnsibleEncryptDecryptRoundTrip(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") } - outputs := []string{ - "$ANSIBLE_VAULT;1.1;AES256\n36333933636666383361613265316136336530613466633831386630646137323161393031653966\n3263346665383030353631646439386333316234626661350a643761626665373830646633323635\n30656136633539303763383835346663396663376436396433613763653137323537623139336266\n3538366365313131300a316261316561333938393730636635316237613638633664636564303537\n3962", - "$ANSIBLE_VAULT;1.1;AES256\n63383038623330333865633238646539363737383961386236363463396334346662356131383839\n3736653463613433323436656339393863636262643234350a353131336336656332343638346633\n62643265616366663630393339333434363235626631656464336264633763393539646138353931\n3737373165396136320a363363616566636632653737393337303765336439663831633637393063\n3831", - } + plugin := newTestPlugin(t, map[string]string{"vault_password": "integration-test-password"}) + + inputs := []string{"secret1", "secret2", "a longer secret value with spaces and punctuation!"} - for ndx, input := range inputs { - exit, out, err := plugin.Call("Encode", []byte(input)) + for _, input := range inputs { + exit, ciphertext, err := plugin.Call("Encode", []byte(input)) if err != nil { t.Fatalf("Encode(%q) error = %v", input, err) } if exit != 0 { - t.Fatalf("Encode(%q) exit = %d, want 0", input, exit) + t.Fatalf("Encode(%q) exit = %d, want 0 (err=%q)", input, exit, plugin.GetError()) + } + + exit, plaintext, err := plugin.Call("Decode", ciphertext) + if err != nil { + t.Fatalf("Decode(%q) error = %v", ciphertext, err) + } + if exit != 0 { + t.Fatalf("Decode(%q) exit = %d, want 0 (err=%q)", ciphertext, exit, plugin.GetError()) } - // Verify each encryption is different (has randomness) - output := string(out) - _, _ = ndx, outputs - if output != outputs[ndx] { - t.Errorf("Encode(%q) output = %q, want %q", input, output, outputs[ndx]) + if string(plaintext) != input { + t.Errorf("round trip mismatch: got %q, want %q", plaintext, input) } } } -func TestAnsibleDecodeIntegration(t *testing.T) { +// TestAnsibleEncode_ProducesDifferentCiphertextEachTime verifies the +// randomness claim the previous version of this test only asserted in a +// comment: encoding the same input twice must not produce identical +// ciphertext (a random salt is mixed into every encryption). +func TestAnsibleEncode_ProducesDifferentCiphertextEachTime(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test") } - ctx := context.Background() - wasmPath := pluginPath + plugin := newTestPlugin(t, map[string]string{"vault_password": "integration-test-password"}) - manifest := extism.Manifest{ - Wasm: []extism.Wasm{ - extism.WasmFile{ - Path: wasmPath, - }, - }, - Config: map[string]string{ - "vault_password": "integration-test-password", - }, + _, first, err := plugin.Call("Encode", []byte("same-input")) + if err != nil { + t.Fatalf("first Encode error = %v", err) + } + _, second, err := plugin.Call("Encode", []byte("same-input")) + if err != nil { + t.Fatalf("second Encode error = %v", err) } - config := extism.PluginConfig{ - EnableWasi: true, + if string(first) == string(second) { + t.Fatalf("expected two encryptions of the same input to differ (random salt), got identical ciphertext") } +} - plugin, err := extism.NewPlugin(ctx, manifest, config, []extism.HostFunction{}) - if err != nil { - t.Fatalf("Failed to initialize plugin: %v", err) +// TestAnsibleEncode_MissingPasswordReturnsError verifies a clean, non-panic +// error when no vault_password is configured. +func TestAnsibleEncode_MissingPasswordReturnsError(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") } - defer plugin.Close(context.Background()) - // Test multiple encryptions - inputs := []string{ - "$ANSIBLE_VAULT;1.1;AES256\n36333933636666383361613265316136336530613466633831386630646137323161393031653966\n3263346665383030353631646439386333316234626661350a643761626665373830646633323635\n30656136633539303763383835346663396663376436396433613763653137323537623139336266\n3538366365313131300a316261316561333938393730636635316237613638633664636564303537\n3962", - "$ANSIBLE_VAULT;1.1;AES256\n63383038623330333865633238646539363737383961386236363463396334346662356131383839\n3736653463613433323436656339393863636262643234350a353131336336656332343638346633\n62643265616366663630393339333434363235626631656464336264633763393539646138353931\n3737373165396136320a363363616566636632653737393337303765336439663831633637393063\n3831", + plugin := newTestPlugin(t, map[string]string{}) + + exit, _, err := plugin.Call("Encode", []byte("secret")) + if exit == 0 { + t.Fatalf("Call(Encode) exit = 0, want non-zero when no vault_password is configured") } + if err == nil { + t.Fatalf("Call(Encode) expected an error when no vault_password is configured, got nil") + } + if !strings.Contains(err.Error(), "vault password") { + t.Fatalf("expected error mentioning a missing vault password, got %q", err) + } +} - outputs := []string{ - "secret1", - "secret2", +// TestAnsibleDecode_MissingPasswordReturnsError mirrors the Encode case +// for Decode. +func TestAnsibleDecode_MissingPasswordReturnsError(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") } - for ndx, input := range inputs { - exit, out, err := plugin.Call("Decode", []byte(input)) - if err != nil { - t.Fatalf("Decode(%q) error = %v", input, err) - } - if exit != 0 { - t.Fatalf("Decode(%q) exit = %d, want 0", input, exit) - } + plugin := newTestPlugin(t, map[string]string{}) - // Verify each encryption is different (has randomness) - output := string(out) - _, _ = ndx, outputs - if output != outputs[ndx] { - t.Errorf("Decode(%q) output = %q, want %q", input, output, outputs[ndx]) - } + exit, _, err := plugin.Call("Decode", []byte("$ANSIBLE_VAULT;1.1;AES256\nirrelevant")) + if exit == 0 { + t.Fatalf("Call(Decode) exit = 0, want non-zero when no vault_password is configured") + } + if err == nil { + t.Fatalf("Call(Decode) expected an error when no vault_password is configured, got nil") + } + if !strings.Contains(err.Error(), "vault password") { + t.Fatalf("expected error mentioning a missing vault password, got %q", err) + } +} + +// TestAnsibleDecode_InvalidFormatReturnsError verifies that malformed vault +// content (missing the "$ANSIBLE_VAULT;..." header) is rejected cleanly +// with a correctly configured password, rather than panicking. +func TestAnsibleDecode_InvalidFormatReturnsError(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + plugin := newTestPlugin(t, map[string]string{"vault_password": "integration-test-password"}) + + exit, _, err := plugin.Call("Decode", []byte("this is not a valid ansible vault payload")) + if exit == 0 { + t.Fatalf("Call(Decode) exit = 0, want non-zero for malformed vault content") + } + if err == nil { + t.Fatalf("expected a non-empty error for malformed vault content") } } diff --git a/go.mod b/go.mod index 4e7da64..732ec05 100644 --- a/go.mod +++ b/go.mod @@ -1,24 +1,21 @@ module github.come/esnet/gdg-plugins -go 1.25.5 +go 1.27.0 require ( - filippo.io/age v1.3.1 github.com/extism/go-pdk v1.1.3 github.com/extism/go-sdk v1.7.1 github.com/matryer/is v1.4.1 github.com/sosedoff/ansible-vault-go v0.2.0 + golang.org/x/crypto v0.45.0 ) require ( - filippo.io/hpke v0.4.0 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca // indirect github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect github.com/tetratelabs/wazero v1.9.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect - golang.org/x/crypto v0.45.0 // indirect - golang.org/x/sys v0.38.0 // indirect google.golang.org/protobuf v1.34.2 // indirect ) diff --git a/go.sum b/go.sum index 45af5ca..8ad4b54 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,3 @@ -c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M= -c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= -filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0= -filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4= -filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= -filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a h1:UwSIFv5g5lIvbGgtf3tVwC7Ky9rmMFBp0RMs+6f6YqE= @@ -34,8 +28,6 @@ go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeX go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= diff --git a/lookup/gsm/gsm_plugin_test.go b/lookup/gsm/gsm_plugin_test.go new file mode 100644 index 0000000..dfd1726 --- /dev/null +++ b/lookup/gsm/gsm_plugin_test.go @@ -0,0 +1,128 @@ +//go:build !tinygo +// +build !tinygo + +package main + +import ( + "context" + "strings" + "testing" + + extism "github.com/extism/go-sdk" +) + +const pluginPath = "../../plugins/lookup_gsm.wasm" + +// newTestHostFunction builds the get_gcp_access_token host function using +// the exact name/namespace/signature GDG registers in production (see +// internal/adapter/plugins/lookup/gsm/host_functions.go), but backed by a +// fixed test token (or a forced failure) instead of a real oauth2.TokenSource. +// The compiled guest links against this function at instantiation time +// regardless of whether a given test path ends up calling it, so every test +// below must supply one. +func newTestHostFunction(token string, fail bool) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "get_gcp_access_token", + func(_ context.Context, p *extism.CurrentPlugin, stack []uint64) { + if fail { + stack[0] = 0 + return + } + offset, err := p.WriteString(token) + if err != nil { + stack[0] = 0 + return + } + stack[0] = offset + }, + []extism.ValueType{}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newTestPlugin(t *testing.T, hostFn extism.HostFunction) *extism.Plugin { + t.Helper() + ctx := context.Background() + + manifest := extism.Manifest{ + Wasm: []extism.Wasm{ + extism.WasmFile{Path: pluginPath}, + }, + AllowedHosts: []string{"secretmanager.googleapis.com"}, + } + + config := extism.PluginConfig{ + EnableWasi: true, + } + + plugin, err := extism.NewPlugin(ctx, manifest, config, []extism.HostFunction{hostFn}) + if err != nil { + t.Fatalf("Failed to initialize plugin: %v", err) + } + t.Cleanup(func() { plugin.Close(context.Background()) }) + return plugin +} + +// TestLookup_EmptyResourceNameReturnsError verifies the guest rejects an +// empty input before ever calling the host for a token — no network +// involved, so this runs even under `go test -short`. +func TestLookup_EmptyResourceNameReturnsError(t *testing.T) { + plugin := newTestPlugin(t, newTestHostFunction("unused", false)) + + exit, _, err := plugin.Call("Lookup", []byte("")) + if exit == 0 { + t.Fatalf("Call(Lookup, \"\") exit = 0, want non-zero") + } + if err == nil { + t.Fatalf("Call(Lookup, \"\") expected an error for an empty resource name, got nil") + } + if !strings.Contains(err.Error(), "no secret resource name provided") { + t.Fatalf("expected error mentioning a missing resource name, got %q", err) + } +} + +// TestLookup_HostTokenFailureReturnsError verifies that when the host's +// get_gcp_access_token function reports failure (offset 0 — see +// handleGetAccessToken on the host side), the guest fails cleanly without +// ever attempting an HTTP request. No network involved. +func TestLookup_HostTokenFailureReturnsError(t *testing.T) { + plugin := newTestPlugin(t, newTestHostFunction("", true)) + + exit, _, err := plugin.Call("Lookup", []byte("projects/example-project/secrets/example-secret/versions/1")) + if exit == 0 { + t.Fatalf("Call(Lookup, ...) exit = 0, want non-zero") + } + if err == nil { + t.Fatalf("Call(Lookup, ...) expected an error when the host fails to provide a token, got nil") + } + if !strings.Contains(err.Error(), "did not return a GCP access token") { + t.Fatalf("expected error mentioning a missing access token, got %q", err) + } +} + +// TestLookupIntegration_RealAPICallWithFakeTokenIsRejected exercises the +// full HTTP round trip against the real Secret Manager API using an +// intentionally-invalid bearer token. It requires network access to +// secretmanager.googleapis.com but no real GCP project or credentials — +// Google is expected to reject the fake token, which is exactly the path +// this test verifies: a non-2xx response is surfaced as a clean error +// rather than a panic or a malformed lookup result. +// +// This is the only test in this file that reaches the real network, so it +// is skipped under `go test -short`. +func TestLookupIntegration_RealAPICallWithFakeTokenIsRejected(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test that requires network access") + } + + plugin := newTestPlugin(t, newTestHostFunction("not-a-real-access-token", false)) + + exit, _, err := plugin.Call("Lookup", []byte("projects/example-project/secrets/example-secret/versions/1")) + if exit == 0 { + t.Fatalf("Call(Lookup, ...) exit = 0, want non-zero (a fake token must be rejected by the real API)") + } + if err == nil { + t.Fatalf("expected a non-empty error from the rejected request, got nil") + } + t.Logf("received expected rejection from Secret Manager: %s", err) +} diff --git a/lookup/gsm/plugin.go b/lookup/gsm/plugin.go new file mode 100644 index 0000000..1f265fa --- /dev/null +++ b/lookup/gsm/plugin.go @@ -0,0 +1,127 @@ +//go:build tinygo +// +build tinygo + +package main + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + + "github.com/extism/go-pdk" +) + +// get_gcp_access_token is a host function provided by GDG (see +// internal/adapter/plugins/lookup/gsm/host_functions.go on the host side). +// It returns the offset of a freshly minted GCP OAuth2 access token written +// into this plugin's own memory, or 0 if the host failed to obtain one (the +// underlying cause is logged host-side, not passed back over this simple +// stack-based ABI). Declared with the classic TinyGo import pragmas (rather +// than //go:wasmimport) to match extism's own reference guest examples for +// the "-target wasi" TinyGo build this repo uses. +// +//go:wasm-module extism:host/user +//export get_gcp_access_token +func get_gcp_access_token() uint64 + +// secretManagerBaseURL is the Google Secret Manager REST API endpoint this +// plugin talks to. The host-side manifest's AllowedHosts (set in +// gsm_lookup.go on the GDG side) restricts this plugin's outbound HTTP +// access to exactly this host — any other destination is rejected by the +// extism runtime before the request ever leaves the process. +const secretManagerBaseURL = "https://secretmanager.googleapis.com/v1/" + +// accessSecretVersionResponse mirrors the fields of Secret Manager's +// AccessSecretVersionResponse that this plugin actually needs. The real +// response also includes "name" and "payload.dataCrc32c", both ignored +// here. +type accessSecretVersionResponse struct { + Payload struct { + Data string `json:"data"` + } `json:"payload"` +} + +// googleAPIErrorResponse mirrors Google's standard JSON error envelope, so +// a failed request surfaces a real message (e.g. "PERMISSION_DENIED: ...") +// instead of just a bare HTTP status code. +type googleAPIErrorResponse struct { + Error struct { + Code int `json:"code"` + Message string `json:"message"` + Status string `json:"status"` + } `json:"error"` +} + +// fetchAccessToken asks the host for a fresh, short-lived GCP access token +// immediately before every request. Tokens are never cached in this +// plugin's own state (it has none) and never baked into static config, so +// they can't go stale even though the host-side extism.Plugin instance +// wrapping this guest is itself cached for the life of the GDG process — +// see NewPluginLookupGSM on the host side. +func fetchAccessToken() (string, error) { + offset := get_gcp_access_token() + if offset == 0 { + return "", errors.New("host did not return a GCP access token (see GDG host logs for the underlying cause)") + } + return pdk.ParamString(offset), nil +} + +// Lookup resolves a Secret Manager resource name (e.g. +// "projects//secrets//versions/", passed as this function's +// input) to its secret value by calling Secret Manager's +// AccessSecretVersion REST endpoint directly. Any ".json_field" suffix a +// caller used in a "lookup:gsm:." reference is stripped off +// and applied host-side by the resolver (see +// internal/adapter/plugins/lookup/resolver.go) after this call returns — +// this plugin only ever sees the bare resource name. +// +//export Lookup +func Lookup() int32 { + resourceName := pdk.InputString() + if resourceName == "" { + pdk.SetError(errors.New("gsm lookup plugin: no secret resource name provided")) + return 1 + } + + token, err := fetchAccessToken() + if err != nil { + pdk.SetError(fmt.Errorf("gsm lookup plugin: %w", err)) + return 1 + } + + url := fmt.Sprintf("%s%s:access", secretManagerBaseURL, resourceName) + req := pdk.NewHTTPRequest(pdk.MethodGet, url) + req.SetHeader("Authorization", "Bearer "+token) + resp := req.Send() + + body := resp.Body() + + if status := resp.Status(); status < 200 || status >= 300 { + message := fmt.Sprintf("HTTP %d", status) + var apiErr googleAPIErrorResponse + if jsonErr := json.Unmarshal(body, &apiErr); jsonErr == nil && apiErr.Error.Message != "" { + message = fmt.Sprintf("HTTP %d: %s (%s)", status, apiErr.Error.Message, apiErr.Error.Status) + } + pdk.SetError(fmt.Errorf("gsm lookup plugin: failed to access secret %q: %s", resourceName, message)) + return 1 + } + + var parsed accessSecretVersionResponse + if err := json.Unmarshal(body, &parsed); err != nil { + pdk.SetError(fmt.Errorf("gsm lookup plugin: failed to parse Secret Manager response: %w", err)) + return 1 + } + + decoded, err := base64.StdEncoding.DecodeString(parsed.Payload.Data) + if err != nil { + pdk.SetError(fmt.Errorf("gsm lookup plugin: failed to base64-decode secret payload: %w", err)) + return 1 + } + + mem := pdk.AllocateBytes(decoded) + pdk.OutputMemory(mem) + return 0 +} + +func main() {} diff --git a/plugin_registry.json b/plugin_registry.json index 81c1772..086d07d 100644 --- a/plugin_registry.json +++ b/plugin_registry.json @@ -30,5 +30,21 @@ ] } ] + }, + { + "name": "gsm", + "type": "lookup", + "description": "Google Secret Manager lookup plugin", + "source": "https://github.com/esnet/gdg-plugins/tree/main/lookup/gsm", + "urlPattern": "https://github.com/esnet/gdg-plugins/raw/refs/tags/{version}/plugins/lookup_gsm.wasm", + "versions": [ + { + "version": "0.1.0", + "minimum_version": "0.9.7", + "config_fields": [ + "credentials" + ] + } + ] } ] diff --git a/plugins/lookup_gsm.wasm b/plugins/lookup_gsm.wasm new file mode 100644 index 0000000..5a3690c Binary files /dev/null and b/plugins/lookup_gsm.wasm differ