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 <noreply@anthropic.com>
This commit is contained in:
Tyler J King 2026-05-13 03:59:08 -04:00
parent f3e1d161d0
commit fe5e2cf3c6
10 changed files with 974 additions and 3 deletions

View file

@ -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/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/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/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 FROM debian:bookworm-slim AS runtime
@ -43,5 +44,5 @@ COPY --from=builder /plugins/ /plugins/
RUN chmod -R a+rx /plugins RUN chmod -R a+rx /plugins
LABEL org.opencontainers.image.source="https://git.guildhouse.dev/tking/guildhouse-spire-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" org.opencontainers.image.licenses="Apache-2.0"

173
GSAP-ATTESTOR-DESIGN.md Normal file
View file

@ -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 117190),
`gsh/gsh/src/human.rs` (lines 261262).
| 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/<agent-id> \
-selector gsap:principal_did:<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/<agent-id> \
-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
```

View file

@ -5,7 +5,8 @@ PLUGINS := \
oidc-attestor \ oidc-attestor \
ssh-credential-composer \ ssh-credential-composer \
governance-notifier \ governance-notifier \
substrate-keymanager substrate-keymanager \
gsap-attestor
.PHONY: all build test lint clean proto-gen .PHONY: all build test lint clean proto-gen

View file

@ -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
}

View file

@ -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)
}
}

46
cmd/gsap-attestor/main.go Normal file
View file

@ -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,
})
}

55
cmd/gsap-attestor/proc.go Normal file
View file

@ -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)
}

View file

@ -39,3 +39,10 @@ plugins:
issuer: https://keycloak.guildhouse.example.org/realms/platform issuer: https://keycloak.guildhouse.example.org/realms/platform
audience: spire audience: spire
token_path: /var/run/secrets/oidc/token 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

52
pkg/gsap/selectors.go Normal file
View file

@ -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",
}

101
pkg/gsap/selectors_test.go Normal file
View file

@ -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)
}
}