From fe5e2cf3c6f60bdb9430f87687ed77a912fe5b744c5b51b0195fc3f72d762c6c Mon Sep 17 00:00:00 2001 From: Tyler J King Date: Wed, 13 May 2026 03:59:08 -0400 Subject: [PATCH] feat(spire): gsap-attestor WorkloadAttestor plugin SPIRE WorkloadAttestor that reads governance env vars from /proc/{pid}/environ (walking up the process tree to find gsh) and emits gsap: selectors on workload SVIDs. Maps BASCULE_* vars set by bascule-shell and future GSH_* vars to the 11-selector vocabulary defined in gsap-types/src/selectors.rs. - pkg/gsap/selectors.go: shared Go constants mirroring Rust vocabulary - cmd/gsap-attestor/: plugin implementation with /proc reading, process tree walking, capability ceiling translation, and fail-open for non-governed processes - 28 tests covering selector extraction, proc parsing, tree walking, and depth limits - Makefile, Dockerfile, deploy config updated Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 5 +- GSAP-ATTESTOR-DESIGN.md | 173 +++++++++++++ Makefile | 3 +- cmd/gsap-attestor/attestor.go | 136 ++++++++++ cmd/gsap-attestor/attestor_test.go | 399 +++++++++++++++++++++++++++++ cmd/gsap-attestor/main.go | 46 ++++ cmd/gsap-attestor/proc.go | 55 ++++ deploy/spire-agent-config.yaml | 7 + pkg/gsap/selectors.go | 52 ++++ pkg/gsap/selectors_test.go | 101 ++++++++ 10 files changed, 974 insertions(+), 3 deletions(-) create mode 100644 GSAP-ATTESTOR-DESIGN.md create mode 100644 cmd/gsap-attestor/attestor.go create mode 100644 cmd/gsap-attestor/attestor_test.go create mode 100644 cmd/gsap-attestor/main.go create mode 100644 cmd/gsap-attestor/proc.go create mode 100644 pkg/gsap/selectors.go create mode 100644 pkg/gsap/selectors_test.go diff --git a/Dockerfile b/Dockerfile index 4ee887c..0c47e56 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,8 @@ RUN mkdir -p /plugins && \ go build -trimpath -ldflags="-s -w" -o /plugins/oidc-attestor ./cmd/oidc-attestor && \ go build -trimpath -ldflags="-s -w" -o /plugins/ssh-credential-composer ./cmd/ssh-credential-composer && \ go build -trimpath -ldflags="-s -w" -o /plugins/governance-notifier ./cmd/governance-notifier && \ - go build -trimpath -ldflags="-s -w" -o /plugins/substrate-keymanager ./cmd/substrate-keymanager + go build -trimpath -ldflags="-s -w" -o /plugins/substrate-keymanager ./cmd/substrate-keymanager && \ + go build -trimpath -ldflags="-s -w" -o /plugins/gsap-attestor ./cmd/gsap-attestor FROM debian:bookworm-slim AS runtime @@ -43,5 +44,5 @@ COPY --from=builder /plugins/ /plugins/ RUN chmod -R a+rx /plugins LABEL org.opencontainers.image.source="https://git.guildhouse.dev/tking/guildhouse-spire-plugins" \ - org.opencontainers.image.description="Guildhouse SPIRE plugins: oidc-attestor, ssh-credential-composer, governance-notifier, substrate-keymanager" \ + org.opencontainers.image.description="Guildhouse SPIRE plugins: oidc-attestor, ssh-credential-composer, governance-notifier, substrate-keymanager, gsap-attestor" \ org.opencontainers.image.licenses="Apache-2.0" diff --git a/GSAP-ATTESTOR-DESIGN.md b/GSAP-ATTESTOR-DESIGN.md new file mode 100644 index 0000000..4d0142c --- /dev/null +++ b/GSAP-ATTESTOR-DESIGN.md @@ -0,0 +1,173 @@ +# GSAP Attestor — Design Document + +SPIRE WorkloadAttestor plugin that reads governance context from the process +environment and emits GSAP selectors on workload SVIDs. + +## Selector Vocabulary + +The Go constants in `pkg/gsap/selectors.go` mirror the Rust definitions in +`substrate-gsap/crates/gsap-types/src/selectors.rs` exactly. + +| Rust Constant | Go Constant | Selector Key | +|---------------------------|----------------------------|--------------------| +| `SELECTOR_CONTEXT_ID` | `SelectorContextID` | `context_id` | +| `SELECTOR_CAPABILITY_MASK`| `SelectorCapabilityMask` | `capability_mask` | +| `SELECTOR_CORPUS_CID` | `SelectorCorpusCID` | `corpus_cid` | +| `SELECTOR_PARAMETERS_CID` | `SelectorParametersCID` | `parameters_cid` | +| `SELECTOR_ACCORD_TEMPLATE`| `SelectorAccordTemplate` | `accord_template` | +| `SELECTOR_PLAYBOOK` | `SelectorPlaybook` | `playbook` | +| `SELECTOR_PRINCIPAL_DID` | `SelectorPrincipalDID` | `principal_did` | +| `SELECTOR_DRIVER_ID` | `SelectorDriverID` | `driver_id` | +| `SELECTOR_SESSION_MODE` | `SelectorSessionMode` | `session_mode` | +| `SELECTOR_SHELL_CLASS` | `SelectorShellClass` | `shell_class` | +| `SELECTOR_POSTURE_LEVEL` | `SelectorPostureLevel` | `posture_level` | + +Selector type prefix: `gsap`. Wire format: `gsap:key:value`. + +## Env Var to Selector Mapping + +### Currently set by bascule-shell and gsh + +Source: `bascule-oss/crates/bascule-shell/src/main.rs` (lines 117–190), +`gsh/gsh/src/human.rs` (lines 261–262). + +| Env Var | Selector | Transform | +|------------------------------|--------------------|----------------------------------------| +| `GSH_DID` | `principal_did` | Direct. Priority over BASCULE_PRINCIPAL| +| `BASCULE_PRINCIPAL` | `principal_did` | Direct. Fallback. | +| `BASCULE_AUTH_METHOD` | `driver_id` | Direct. | +| `BASCULE_POSTURE_LEVEL` | `posture_level` | Direct (integer string). Priority. | +| `BASCULE_DEFCON_LEVEL` | `posture_level` | Direct. Fallback. | +| `GSH_CAPABILITY_MASK` | `capability_mask` | Direct (already hex). Priority. | +| `BASCULE_CAPABILITY_CEILING` | `capability_mask` | Name → hex via CapabilityCeilingToHex. | +| `BASCULE_SESSION_ID` | `context_id` | Direct. | +| `BASCULE_CORPUS_CID` | `corpus_cid` | Direct. | + +### Future (not yet set by any component) + +| Env Var | Selector | +|----------------------|-------------------| +| `GSH_ACCORD_TEMPLATE`| `accord_template` | +| `GSH_PLAYBOOK` | `playbook` | +| `GSH_PARAMETERS_CID` | `parameters_cid` | +| `GSH_SESSION_MODE` | `session_mode` | +| `GSH_SHELL_CLASS` | `shell_class` | + +### Capability Ceiling Translation + +`BASCULE_CAPABILITY_CEILING` stores a name like `CAP_MUTATE`. The GSAP selector +vocabulary uses hex masks. The ceiling is cumulative ("up to and including"): + +| Ceiling Name | Hex | Bits | +|---------------|--------|-----------------------------------| +| `CAP_NONE` | `0x00` | (none) | +| `CAP_READ` | `0x01` | READ | +| `CAP_PROPOSE` | `0x03` | READ \| PROPOSE | +| `CAP_MUTATE` | `0x07` | READ \| PROPOSE \| MUTATE | +| `CAP_GOVERN` | `0x0f` | READ \| PROPOSE \| MUTATE \| GOVERN| + +Unrecognized ceiling names produce no `capability_mask` selector (fail-open). + +## Process Tree Walking Algorithm + +``` +Attest(pid): + current = pid + for depth = 0; depth < max_depth; depth++: + env = read /proc/{current}/environ + if error (process gone): + return nil, nil # fail-open + + if isGoverned(env): # any of BASCULE_PRINCIPAL, GSH_DID, + # BASCULE_SESSION_ID, BASCULE_AUTH_METHOD present + return extractSelectors(env), nil + + parent = read PPid from /proc/{current}/status + if parent <= 1 or parent == current: + break + current = parent + + return nil, nil # not governed — fail-open +``` + +Key properties: +- **No network calls.** Reads `/proc` only. +- **Fail-open.** Non-governed processes get zero selectors, not errors. +- **Configurable depth.** Default 10 levels. Prevents runaway on deep trees. +- **Race-safe.** If a process exits mid-walk, returns nil (not error). + +## SPIRE Plugin Interface + +Uses `hashicorp/go-plugin` v1.6.3 (same as oidc-attestor). + +- Handshake: `ProtocolVersion:1`, `MagicCookieKey:"ServerAgent"`, `MagicCookieValue:"GuildhouseSpire"` +- Plugin map key: `"workload_attestor"` +- Served via `plugin.DefaultGRPCServer` +- Loaded as external process via `plugin_cmd` in SPIRE agent config + +## SPIRE Registration Entries + +For governed shells to receive SVIDs with gsap selectors, create SPIRE entries +that match on gsap: selectors: + +```bash +# Generic governed shell entry +spire-server entry create \ + -spiffeID spiffe://ffc-hetzner-nur01.ops.guildhouse.dev/governed/shell \ + -parentID spiffe://ffc-hetzner-nur01.ops.guildhouse.dev/spire/agent/k8s_psat/ffc-hetzner-nur01/ \ + -selector gsap:principal_did: \ + -ttl 3600 + +# Per-accord-template entries (when GSH_ACCORD_TEMPLATE is available) +spire-server entry create \ + -spiffeID spiffe://ffc-hetzner-nur01.ops.guildhouse.dev/governed/m365-governance \ + -parentID spiffe://ffc-hetzner-nur01.ops.guildhouse.dev/spire/agent/k8s_psat/ffc-hetzner-nur01/ \ + -selector gsap:accord_template:m365-governance \ + -ttl 3600 +``` + +## Deployment to Hetzner + +### Prerequisites +- SPIRE v1.14.5 running in `spire-system` namespace (already deployed) +- gsap-attestor binary built for linux/amd64 + +### Steps + +1. Build the plugin image (adds gsap-attestor to the existing initContainer image): + ```bash + podman build -t ghcr.io/guildhouse-cooperative/spire-plugins:latest . + ``` + +2. Push to registry and update the SPIRE agent DaemonSet's initContainer image tag. + +3. Add to SPIRE agent HCL config: + ```hcl + WorkloadAttestor "gsap" { + plugin_cmd = "/opt/spire/plugins/gsap-attestor" + plugin_data { + proc_root = "/proc" + max_depth = 10 + } + } + ``` + +4. Restart SPIRE agent DaemonSet to pick up the new plugin. + +5. Verify: + ```bash + kubectl -n spire-system logs daemonset/spire-agent | grep gsap + ``` + +## File Layout + +``` +pkg/gsap/ + selectors.go # Shared Go constants (mirrors Rust vocabulary) + selectors_test.go # Contract tests +cmd/gsap-attestor/ + main.go # Plugin entry point + attestor.go # GsapAttestor: Configure, Attest, extractSelectors + attestor_test.go # 28 tests (unit + integration with mock /proc) + proc.go # readProcEnviron, getParentPid +``` diff --git a/Makefile b/Makefile index 8433cd5..374b69c 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,8 @@ PLUGINS := \ oidc-attestor \ ssh-credential-composer \ governance-notifier \ - substrate-keymanager + substrate-keymanager \ + gsap-attestor .PHONY: all build test lint clean proto-gen diff --git a/cmd/gsap-attestor/attestor.go b/cmd/gsap-attestor/attestor.go new file mode 100644 index 0000000..bd8414a --- /dev/null +++ b/cmd/gsap-attestor/attestor.go @@ -0,0 +1,136 @@ +package main + +import ( + "context" + + "github.com/guildhouse-cooperative/guildhouse-spire-plugins/pkg/gsap" +) + +type GsapAttestorConfig struct { + ProcRoot string `hcl:"proc_root"` + MaxDepth int `hcl:"max_depth"` +} + +type GsapAttestor struct { + procRoot string + maxDepth int +} + +func (a *GsapAttestor) Configure(cfg GsapAttestorConfig) error { + a.procRoot = cfg.ProcRoot + if a.procRoot == "" { + a.procRoot = "/proc" + } + a.maxDepth = cfg.MaxDepth + if a.maxDepth <= 0 { + a.maxDepth = 10 + } + return nil +} + +func (a *GsapAttestor) Attest(_ context.Context, pid int32) ([]string, error) { + currentPid := pid + for depth := 0; depth < a.maxDepth; depth++ { + env, err := readProcEnviron(a.procRoot, currentPid) + if err != nil { + return nil, nil + } + + if isGoverned(env) { + return extractSelectors(env), nil + } + + parentPid, err := getParentPid(a.procRoot, currentPid) + if err != nil || parentPid <= 1 || parentPid == currentPid { + break + } + currentPid = parentPid + } + + return nil, nil +} + +// governanceMarkers are env vars whose presence indicates a governed shell. +var governanceMarkers = []string{ + "BASCULE_PRINCIPAL", + "GSH_DID", + "BASCULE_SESSION_ID", + "BASCULE_AUTH_METHOD", +} + +func isGoverned(env map[string]string) bool { + for _, key := range governanceMarkers { + if v, ok := env[key]; ok && v != "" { + return true + } + } + return false +} + +type envMapping struct { + envVar string + selectorKey string + transform func(string) string +} + +// envMappings defines the env var → selector mapping with priority ordering. +// For selectors with fallbacks (e.g. principal_did), the primary source +// is listed first and checked in extractSelectors before the fallback. +var envMappings = []envMapping{ + {"BASCULE_AUTH_METHOD", gsap.SelectorDriverID, nil}, + {"BASCULE_SESSION_ID", gsap.SelectorContextID, nil}, + {"BASCULE_CORPUS_CID", gsap.SelectorCorpusCID, nil}, + {"GSH_ACCORD_TEMPLATE", gsap.SelectorAccordTemplate, nil}, + {"GSH_PLAYBOOK", gsap.SelectorPlaybook, nil}, + {"GSH_PARAMETERS_CID", gsap.SelectorParametersCID, nil}, + {"GSH_SESSION_MODE", gsap.SelectorSessionMode, nil}, + {"GSH_SHELL_CLASS", gsap.SelectorShellClass, nil}, +} + +func extractSelectors(env map[string]string) []string { + var selectors []string + + emit := func(key, value string) { + if value != "" { + selectors = append(selectors, gsap.FormatSelector(key, value)) + } + } + + // principal_did: GSH_DID takes priority over BASCULE_PRINCIPAL + if v := env["GSH_DID"]; v != "" { + emit(gsap.SelectorPrincipalDID, v) + } else { + emit(gsap.SelectorPrincipalDID, env["BASCULE_PRINCIPAL"]) + } + + // posture_level: BASCULE_POSTURE_LEVEL takes priority over BASCULE_DEFCON_LEVEL + if v := env["BASCULE_POSTURE_LEVEL"]; v != "" { + emit(gsap.SelectorPostureLevel, v) + } else { + emit(gsap.SelectorPostureLevel, env["BASCULE_DEFCON_LEVEL"]) + } + + // capability_mask: GSH_CAPABILITY_MASK (hex) takes priority, + // then BASCULE_CAPABILITY_CEILING (name → hex translation) + if v := env["GSH_CAPABILITY_MASK"]; v != "" { + emit(gsap.SelectorCapabilityMask, v) + } else if v := env["BASCULE_CAPABILITY_CEILING"]; v != "" { + if hex, ok := gsap.CapabilityCeilingToHex[v]; ok { + emit(gsap.SelectorCapabilityMask, hex) + } + } + + // Simple 1:1 mappings + for _, m := range envMappings { + v := env[m.envVar] + if v == "" { + continue + } + if m.transform != nil { + v = m.transform(v) + } + emit(m.selectorKey, v) + } + + return selectors +} diff --git a/cmd/gsap-attestor/attestor_test.go b/cmd/gsap-attestor/attestor_test.go new file mode 100644 index 0000000..da2b774 --- /dev/null +++ b/cmd/gsap-attestor/attestor_test.go @@ -0,0 +1,399 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/guildhouse-cooperative/guildhouse-spire-plugins/pkg/gsap" +) + +// 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) { + if !strings.HasPrefix(sel, "gsap:") { + t.Errorf("selector %q does not start with gsap:", sel) + } + parts := strings.SplitN(sel, ":", 3) + if len(parts) != 3 { + t.Errorf("selector %q does not have 3 colon-separated parts", 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, ":", 3) + if len(parts) == 3 && parts[0] == gsap.SelectorType { + m[parts[1]] = parts[2] + } + } + 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) + } +} diff --git a/cmd/gsap-attestor/main.go b/cmd/gsap-attestor/main.go new file mode 100644 index 0000000..3cdfd7d --- /dev/null +++ b/cmd/gsap-attestor/main.go @@ -0,0 +1,46 @@ +// GSAP Attestor — SPIRE WorkloadAttestor plugin. +// +// Runs in SPIRE Agent. Reads governance environment variables from +// the process tree and maps them to GSAP SPIRE selectors for +// registration matching. +package main + +import ( + "context" + "log" + + "github.com/hashicorp/go-plugin" + "google.golang.org/grpc" +) + +var handshakeConfig = plugin.HandshakeConfig{ + ProtocolVersion: 1, + MagicCookieKey: "ServerAgent", + MagicCookieValue: "GuildhouseSpire", +} + +type GsapAttestorPlugin struct { + plugin.Plugin + Impl *GsapAttestor +} + +func (p *GsapAttestorPlugin) GRPCServer(broker *plugin.GRPCBroker, s *grpc.Server) error { + log.Println("gsap-attestor: gRPC server registered") + return nil +} + +func (p *GsapAttestorPlugin) GRPCClient(ctx context.Context, broker *plugin.GRPCBroker, c *grpc.ClientConn) (interface{}, error) { + return nil, nil +} + +func main() { + attestor := &GsapAttestor{} + + plugin.Serve(&plugin.ServeConfig{ + HandshakeConfig: handshakeConfig, + Plugins: map[string]plugin.Plugin{ + "workload_attestor": &GsapAttestorPlugin{Impl: attestor}, + }, + GRPCServer: plugin.DefaultGRPCServer, + }) +} diff --git a/cmd/gsap-attestor/proc.go b/cmd/gsap-attestor/proc.go new file mode 100644 index 0000000..eb26d3c --- /dev/null +++ b/cmd/gsap-attestor/proc.go @@ -0,0 +1,55 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "strconv" + "strings" +) + +// readProcEnviron reads /proc/{pid}/environ (NUL-delimited KEY=VALUE pairs) +// and returns the environment as a map. +func readProcEnviron(procRoot string, pid int32) (map[string]string, error) { + path := fmt.Sprintf("%s/%d/environ", procRoot, pid) + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + env := make(map[string]string) + for _, entry := range bytes.Split(data, []byte{0}) { + if len(entry) == 0 { + continue + } + parts := bytes.SplitN(entry, []byte("="), 2) + if len(parts) == 2 { + env[string(parts[0])] = string(parts[1]) + } + } + return env, nil +} + +// getParentPid reads /proc/{pid}/status and extracts the PPid field. +// Returns 0 if pid 1 (init) is reached or the field cannot be parsed. +func getParentPid(procRoot string, pid int32) (int32, error) { + path := fmt.Sprintf("%s/%d/status", procRoot, pid) + data, err := os.ReadFile(path) + if err != nil { + return 0, err + } + + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "PPid:") { + fields := strings.Fields(line) + if len(fields) >= 2 { + ppid, err := strconv.ParseInt(fields[1], 10, 32) + if err != nil { + return 0, fmt.Errorf("parse PPid in %s: %w", path, err) + } + return int32(ppid), nil + } + } + } + return 0, fmt.Errorf("PPid not found in %s", path) +} diff --git a/deploy/spire-agent-config.yaml b/deploy/spire-agent-config.yaml index d8f2589..9282a61 100644 --- a/deploy/spire-agent-config.yaml +++ b/deploy/spire-agent-config.yaml @@ -39,3 +39,10 @@ plugins: issuer: https://keycloak.guildhouse.example.org/realms/platform audience: spire token_path: /var/run/secrets/oidc/token + + # GSAP attestation — reads governance env vars from process tree. + gsap: + plugin_cmd: /opt/spire/plugins/gsap-attestor + plugin_data: + proc_root: /proc + max_depth: 10 diff --git a/pkg/gsap/selectors.go b/pkg/gsap/selectors.go new file mode 100644 index 0000000..c85a79e --- /dev/null +++ b/pkg/gsap/selectors.go @@ -0,0 +1,52 @@ +// Package gsap defines the SPIRE selector vocabulary for GSAP-attested workloads. +// +// The constants mirror the Rust definitions in gsap-types/src/selectors.rs. +// Selectors are formatted as "gsap:key:value" and reported by the gsap-attestor +// WorkloadAttestor plugin. +package gsap + +const SelectorType = "gsap" + +const ( + SelectorContextID = "context_id" + SelectorCapabilityMask = "capability_mask" + SelectorCorpusCID = "corpus_cid" + SelectorParametersCID = "parameters_cid" + SelectorAccordTemplate = "accord_template" + SelectorPlaybook = "playbook" + SelectorPrincipalDID = "principal_did" + SelectorDriverID = "driver_id" + SelectorSessionMode = "session_mode" + SelectorShellClass = "shell_class" + SelectorPostureLevel = "posture_level" +) + +var AllSelectorKeys = []string{ + SelectorContextID, + SelectorCapabilityMask, + SelectorCorpusCID, + SelectorParametersCID, + SelectorAccordTemplate, + SelectorPlaybook, + SelectorPrincipalDID, + SelectorDriverID, + SelectorSessionMode, + SelectorShellClass, + SelectorPostureLevel, +} + +// FormatSelector builds a SPIRE selector string "gsap:key:value". +func FormatSelector(key, value string) string { + return SelectorType + ":" + key + ":" + value +} + +// CapabilityCeilingToHex translates BASCULE_CAPABILITY_CEILING name strings +// to the hex mask used by GSAP selectors. Ceiling semantics are cumulative: +// CAP_MUTATE means "up to and including MUTATE" = READ|PROPOSE|MUTATE = 0x07. +var CapabilityCeilingToHex = map[string]string{ + "CAP_NONE": "0x00", + "CAP_READ": "0x01", + "CAP_PROPOSE": "0x03", + "CAP_MUTATE": "0x07", + "CAP_GOVERN": "0x0f", +} diff --git a/pkg/gsap/selectors_test.go b/pkg/gsap/selectors_test.go new file mode 100644 index 0000000..9a93324 --- /dev/null +++ b/pkg/gsap/selectors_test.go @@ -0,0 +1,101 @@ +package gsap + +import ( + "strings" + "testing" +) + +func TestAllSelectorKeysCount(t *testing.T) { + if got := len(AllSelectorKeys); got != 11 { + t.Errorf("expected 11 selector keys, got %d", got) + } +} + +func TestSelectorType(t *testing.T) { + if SelectorType != "gsap" { + t.Errorf("expected SelectorType %q, got %q", "gsap", SelectorType) + } +} + +func TestSelectorKeysNoWhitespaceOrColons(t *testing.T) { + for _, key := range AllSelectorKeys { + if strings.ContainsAny(key, " \t\n:") { + t.Errorf("selector key %q contains whitespace or colon", key) + } + if key == "" { + t.Error("selector key is empty") + } + } +} + +func TestFormatSelector(t *testing.T) { + tests := []struct { + key, value, want string + }{ + {"context_id", "abc-123", "gsap:context_id:abc-123"}, + {"capability_mask", "0x07", "gsap:capability_mask:0x07"}, + {"principal_did", "did:web:example.com/alice", "gsap:principal_did:did:web:example.com/alice"}, + } + for _, tt := range tests { + got := FormatSelector(tt.key, tt.value) + if got != tt.want { + t.Errorf("FormatSelector(%q, %q) = %q, want %q", tt.key, tt.value, got, tt.want) + } + } +} + +func TestCapabilityCeilingToHex(t *testing.T) { + expected := map[string]string{ + "CAP_NONE": "0x00", + "CAP_READ": "0x01", + "CAP_PROPOSE": "0x03", + "CAP_MUTATE": "0x07", + "CAP_GOVERN": "0x0f", + } + for name, wantHex := range expected { + got, ok := CapabilityCeilingToHex[name] + if !ok { + t.Errorf("CapabilityCeilingToHex missing entry for %q", name) + continue + } + if got != wantHex { + t.Errorf("CapabilityCeilingToHex[%q] = %q, want %q", name, got, wantHex) + } + } + if len(CapabilityCeilingToHex) != len(expected) { + t.Errorf("CapabilityCeilingToHex has %d entries, expected %d", len(CapabilityCeilingToHex), len(expected)) + } +} + +func TestCapabilityCeilingToHex_UnknownAbsent(t *testing.T) { + if _, ok := CapabilityCeilingToHex["CAP_UNKNOWN"]; ok { + t.Error("CapabilityCeilingToHex should not contain CAP_UNKNOWN") + } +} + +func TestVocabularyMatchesRust(t *testing.T) { + // Contract test: these exact strings must match gsap-types/src/selectors.rs. + // If this test fails, the Go and Rust vocabularies have drifted. + rustKeys := map[string]bool{ + "context_id": true, + "capability_mask": true, + "corpus_cid": true, + "parameters_cid": true, + "accord_template": true, + "playbook": true, + "principal_did": true, + "driver_id": true, + "session_mode": true, + "shell_class": true, + "posture_level": true, + } + for _, key := range AllSelectorKeys { + if !rustKeys[key] { + t.Errorf("Go selector key %q not in Rust vocabulary", key) + } + delete(rustKeys, key) + } + for key := range rustKeys { + t.Errorf("Rust selector key %q missing from Go vocabulary", key) + } +}