The original implementation used hashicorp/go-plugin directly with a custom handshake, which SPIRE rejected. Switch to spire-plugin-sdk's pluginmain.Serve() for correct WorkloadAttestor protocol negotiation, implement ConfigServer for plugin_data parsing, and return selector values in key:value format (SPIRE infers the type prefix from the plugin name). Config decoding tries JSON first (chart renders YAML as JSON) then falls back to HCL. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
398 lines
10 KiB
Go
398 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
)
|
|
|
|
// writeMockProc creates a fake /proc/{pid} directory with environ and status files.
|
|
func writeMockProc(t *testing.T, root string, pid int32, env map[string]string, ppid int32) {
|
|
t.Helper()
|
|
dir := filepath.Join(root, fmt.Sprintf("%d", pid))
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
for k, v := range env {
|
|
buf.WriteString(k + "=" + v)
|
|
buf.WriteByte(0)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "environ"), buf.Bytes(), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
status := fmt.Sprintf("Name:\tmockproc\nPPid:\t%d\n", ppid)
|
|
if err := os.WriteFile(filepath.Join(dir, "status"), []byte(status), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// --- extractSelectors unit tests (pure, no /proc) ---
|
|
|
|
func TestExtractSelectors_FullGoverned(t *testing.T) {
|
|
env := map[string]string{
|
|
"BASCULE_PRINCIPAL": "did:web:example.com/tyler",
|
|
"BASCULE_AUTH_METHOD": "oidc-entra",
|
|
"BASCULE_POSTURE_LEVEL": "5",
|
|
"BASCULE_CAPABILITY_CEILING": "CAP_MUTATE",
|
|
"BASCULE_SESSION_ID": "sess-123",
|
|
"BASCULE_CORPUS_CID": "sha256:dev-guildhouse-cli",
|
|
}
|
|
selectors := extractSelectors(env)
|
|
|
|
smap := selectorMap(selectors)
|
|
assertSelector(t, smap, "principal_did", "did:web:example.com/tyler")
|
|
assertSelector(t, smap, "driver_id", "oidc-entra")
|
|
assertSelector(t, smap, "posture_level", "5")
|
|
assertSelector(t, smap, "capability_mask", "0x07")
|
|
assertSelector(t, smap, "context_id", "sess-123")
|
|
assertSelector(t, smap, "corpus_cid", "sha256:dev-guildhouse-cli")
|
|
|
|
if len(selectors) != 6 {
|
|
t.Errorf("expected 6 selectors, got %d: %v", len(selectors), selectors)
|
|
}
|
|
}
|
|
|
|
func TestExtractSelectors_GSH_DID_Override(t *testing.T) {
|
|
env := map[string]string{
|
|
"GSH_DID": "did:web:override.com/alice",
|
|
"BASCULE_PRINCIPAL": "did:web:example.com/tyler",
|
|
"BASCULE_AUTH_METHOD": "ssh-key",
|
|
}
|
|
selectors := extractSelectors(env)
|
|
smap := selectorMap(selectors)
|
|
assertSelector(t, smap, "principal_did", "did:web:override.com/alice")
|
|
}
|
|
|
|
func TestExtractSelectors_CapabilityCeiling_AllValues(t *testing.T) {
|
|
tests := []struct {
|
|
ceiling string
|
|
wantHex string
|
|
}{
|
|
{"CAP_NONE", "0x00"},
|
|
{"CAP_READ", "0x01"},
|
|
{"CAP_PROPOSE", "0x03"},
|
|
{"CAP_MUTATE", "0x07"},
|
|
{"CAP_GOVERN", "0x0f"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.ceiling, func(t *testing.T) {
|
|
env := map[string]string{
|
|
"BASCULE_PRINCIPAL": "did:test",
|
|
"BASCULE_CAPABILITY_CEILING": tt.ceiling,
|
|
}
|
|
smap := selectorMap(extractSelectors(env))
|
|
assertSelector(t, smap, "capability_mask", tt.wantHex)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractSelectors_GSH_CAPABILITY_MASK_Override(t *testing.T) {
|
|
env := map[string]string{
|
|
"BASCULE_PRINCIPAL": "did:test",
|
|
"GSH_CAPABILITY_MASK": "0x0f",
|
|
"BASCULE_CAPABILITY_CEILING": "CAP_READ",
|
|
}
|
|
smap := selectorMap(extractSelectors(env))
|
|
assertSelector(t, smap, "capability_mask", "0x0f")
|
|
}
|
|
|
|
func TestExtractSelectors_PostureLevel_Fallback(t *testing.T) {
|
|
env := map[string]string{
|
|
"BASCULE_PRINCIPAL": "did:test",
|
|
"BASCULE_DEFCON_LEVEL": "3",
|
|
}
|
|
smap := selectorMap(extractSelectors(env))
|
|
assertSelector(t, smap, "posture_level", "3")
|
|
}
|
|
|
|
func TestExtractSelectors_PostureLevel_PrimaryWins(t *testing.T) {
|
|
env := map[string]string{
|
|
"BASCULE_PRINCIPAL": "did:test",
|
|
"BASCULE_POSTURE_LEVEL": "5",
|
|
"BASCULE_DEFCON_LEVEL": "3",
|
|
}
|
|
smap := selectorMap(extractSelectors(env))
|
|
assertSelector(t, smap, "posture_level", "5")
|
|
}
|
|
|
|
func TestExtractSelectors_UnknownCeiling(t *testing.T) {
|
|
env := map[string]string{
|
|
"BASCULE_PRINCIPAL": "did:test",
|
|
"BASCULE_CAPABILITY_CEILING": "CAP_UNKNOWN",
|
|
}
|
|
smap := selectorMap(extractSelectors(env))
|
|
if _, ok := smap["capability_mask"]; ok {
|
|
t.Error("expected no capability_mask selector for unknown ceiling")
|
|
}
|
|
}
|
|
|
|
func TestExtractSelectors_EmptyEnv(t *testing.T) {
|
|
selectors := extractSelectors(map[string]string{})
|
|
if len(selectors) != 0 {
|
|
t.Errorf("expected no selectors for empty env, got %v", selectors)
|
|
}
|
|
}
|
|
|
|
func TestExtractSelectors_FutureVars(t *testing.T) {
|
|
env := map[string]string{
|
|
"BASCULE_PRINCIPAL": "did:test",
|
|
"GSH_ACCORD_TEMPLATE": "m365-governance",
|
|
"GSH_PLAYBOOK": "m365:groups:create",
|
|
"GSH_PARAMETERS_CID": "sha256:params",
|
|
"GSH_SESSION_MODE": "true",
|
|
"GSH_SHELL_CLASS": "Human",
|
|
}
|
|
smap := selectorMap(extractSelectors(env))
|
|
assertSelector(t, smap, "accord_template", "m365-governance")
|
|
assertSelector(t, smap, "playbook", "m365:groups:create")
|
|
assertSelector(t, smap, "parameters_cid", "sha256:params")
|
|
assertSelector(t, smap, "session_mode", "true")
|
|
assertSelector(t, smap, "shell_class", "Human")
|
|
}
|
|
|
|
func TestExtractSelectors_Format(t *testing.T) {
|
|
env := map[string]string{
|
|
"BASCULE_PRINCIPAL": "did:test",
|
|
"BASCULE_AUTH_METHOD": "ssh-key",
|
|
"BASCULE_POSTURE_LEVEL": "4",
|
|
}
|
|
for _, sel := range extractSelectors(env) {
|
|
parts := strings.SplitN(sel, ":", 2)
|
|
if len(parts) != 2 {
|
|
t.Errorf("selector %q does not have key:value format", sel)
|
|
}
|
|
if parts[0] == "" || parts[1] == "" {
|
|
t.Errorf("selector %q has empty key or value", sel)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- proc reading tests ---
|
|
|
|
func TestReadProcEnviron_Simple(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeMockProc(t, root, 42, map[string]string{"FOO": "bar", "BAZ": "qux"}, 1)
|
|
|
|
env, err := readProcEnviron(root, 42)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if env["FOO"] != "bar" || env["BAZ"] != "qux" {
|
|
t.Errorf("unexpected env: %v", env)
|
|
}
|
|
}
|
|
|
|
func TestReadProcEnviron_EmptyFile(t *testing.T) {
|
|
root := t.TempDir()
|
|
dir := filepath.Join(root, "42")
|
|
os.MkdirAll(dir, 0755)
|
|
os.WriteFile(filepath.Join(dir, "environ"), []byte{}, 0644)
|
|
|
|
env, err := readProcEnviron(root, 42)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(env) != 0 {
|
|
t.Errorf("expected empty map, got %v", env)
|
|
}
|
|
}
|
|
|
|
func TestReadProcEnviron_Missing(t *testing.T) {
|
|
root := t.TempDir()
|
|
_, err := readProcEnviron(root, 9999)
|
|
if err == nil {
|
|
t.Error("expected error for missing PID")
|
|
}
|
|
}
|
|
|
|
func TestGetParentPid_Normal(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeMockProc(t, root, 100, map[string]string{}, 42)
|
|
|
|
ppid, err := getParentPid(root, 100)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ppid != 42 {
|
|
t.Errorf("expected ppid 42, got %d", ppid)
|
|
}
|
|
}
|
|
|
|
func TestGetParentPid_Init(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeMockProc(t, root, 1, map[string]string{}, 0)
|
|
|
|
ppid, err := getParentPid(root, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ppid != 0 {
|
|
t.Errorf("expected ppid 0, got %d", ppid)
|
|
}
|
|
}
|
|
|
|
// --- Attest integration tests ---
|
|
|
|
func newTestAttestor(root string) *GsapAttestor {
|
|
a := &GsapAttestor{}
|
|
a.Configure(GsapAttestorConfig{ProcRoot: root, MaxDepth: 10})
|
|
return a
|
|
}
|
|
|
|
func TestAttest_DirectProcess(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeMockProc(t, root, 100, map[string]string{
|
|
"BASCULE_PRINCIPAL": "did:web:test/tyler",
|
|
"BASCULE_AUTH_METHOD": "oidc-entra",
|
|
"BASCULE_POSTURE_LEVEL": "5",
|
|
}, 1)
|
|
|
|
a := newTestAttestor(root)
|
|
selectors, err := a.Attest(context.Background(), 100)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(selectors) == 0 {
|
|
t.Fatal("expected selectors for governed process")
|
|
}
|
|
|
|
smap := selectorMap(selectors)
|
|
assertSelector(t, smap, "principal_did", "did:web:test/tyler")
|
|
assertSelector(t, smap, "driver_id", "oidc-entra")
|
|
assertSelector(t, smap, "posture_level", "5")
|
|
}
|
|
|
|
func TestAttest_WalkToParent(t *testing.T) {
|
|
root := t.TempDir()
|
|
// Parent (gsh) has governance vars
|
|
writeMockProc(t, root, 50, map[string]string{
|
|
"BASCULE_PRINCIPAL": "did:web:test/tyler",
|
|
"BASCULE_AUTH_METHOD": "ssh-key",
|
|
}, 1)
|
|
// Child process has no governance vars
|
|
writeMockProc(t, root, 100, map[string]string{
|
|
"HOME": "/home/tyler",
|
|
"PATH": "/usr/bin",
|
|
}, 50)
|
|
|
|
a := newTestAttestor(root)
|
|
selectors, err := a.Attest(context.Background(), 100)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(selectors) == 0 {
|
|
t.Fatal("expected selectors from parent")
|
|
}
|
|
|
|
smap := selectorMap(selectors)
|
|
assertSelector(t, smap, "principal_did", "did:web:test/tyler")
|
|
}
|
|
|
|
func TestAttest_DepthLimit(t *testing.T) {
|
|
root := t.TempDir()
|
|
// Create a chain of 15 processes with no governance vars
|
|
for i := int32(2); i <= 16; i++ {
|
|
writeMockProc(t, root, i, map[string]string{"HOME": "/tmp"}, i-1)
|
|
}
|
|
writeMockProc(t, root, 1, map[string]string{
|
|
"BASCULE_PRINCIPAL": "did:web:test/unreachable",
|
|
}, 0)
|
|
|
|
a := &GsapAttestor{}
|
|
a.Configure(GsapAttestorConfig{ProcRoot: root, MaxDepth: 5})
|
|
|
|
selectors, err := a.Attest(context.Background(), 16)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(selectors) != 0 {
|
|
t.Errorf("expected no selectors beyond depth limit, got %v", selectors)
|
|
}
|
|
}
|
|
|
|
func TestAttest_NonGovernedProcess(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeMockProc(t, root, 100, map[string]string{
|
|
"HOME": "/home/user",
|
|
"PATH": "/usr/bin",
|
|
}, 1)
|
|
writeMockProc(t, root, 1, map[string]string{}, 0)
|
|
|
|
a := newTestAttestor(root)
|
|
selectors, err := a.Attest(context.Background(), 100)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(selectors) != 0 {
|
|
t.Errorf("expected no selectors for non-governed process, got %v", selectors)
|
|
}
|
|
}
|
|
|
|
func TestAttest_MissingProcEntry(t *testing.T) {
|
|
root := t.TempDir()
|
|
a := newTestAttestor(root)
|
|
|
|
selectors, err := a.Attest(context.Background(), 9999)
|
|
if err != nil {
|
|
t.Errorf("expected nil error for missing PID, got %v", err)
|
|
}
|
|
if len(selectors) != 0 {
|
|
t.Errorf("expected no selectors for missing PID, got %v", selectors)
|
|
}
|
|
}
|
|
|
|
func TestAttest_EndToEnd_CapabilityMask(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeMockProc(t, root, 100, map[string]string{
|
|
"BASCULE_PRINCIPAL": "did:web:test/tyler",
|
|
"BASCULE_CAPABILITY_CEILING": "CAP_GOVERN",
|
|
}, 1)
|
|
|
|
a := newTestAttestor(root)
|
|
selectors, err := a.Attest(context.Background(), 100)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
smap := selectorMap(selectors)
|
|
assertSelector(t, smap, "capability_mask", "0x0f")
|
|
}
|
|
|
|
// --- test helpers ---
|
|
|
|
func selectorMap(selectors []string) map[string]string {
|
|
m := make(map[string]string)
|
|
for _, sel := range selectors {
|
|
parts := strings.SplitN(sel, ":", 2)
|
|
if len(parts) == 2 {
|
|
m[parts[0]] = parts[1]
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func assertSelector(t *testing.T, smap map[string]string, key, want string) {
|
|
t.Helper()
|
|
got, ok := smap[key]
|
|
if !ok {
|
|
keys := make([]string, 0, len(smap))
|
|
for k := range smap {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
t.Errorf("selector %q not found; present keys: %v", key, keys)
|
|
return
|
|
}
|
|
if got != want {
|
|
t.Errorf("selector %q = %q, want %q", key, got, want)
|
|
}
|
|
}
|