Skip to content
Open
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
45 changes: 45 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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 ./...
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:<provider>:<key>[.<json_field>]` 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
Expand All @@ -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/<output-name>.wasm -target wasi -no-debug -tags=purego -scheduler=none <path>/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
Expand Down
5 changes: 5 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -35,6 +39,7 @@ tasks:
cmds:
- task: cipher_ansible
- task: cipher_aes
- task: lookup_gsm
deps:
- task: clean
- task: init
210 changes: 138 additions & 72 deletions cipher/aes-256-gcm/aes256_plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading
Loading