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:
parent
f3e1d161d0
commit
fe5e2cf3c6
10 changed files with 974 additions and 3 deletions
|
|
@ -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"
|
||||
|
|
|
|||
173
GSAP-ATTESTOR-DESIGN.md
Normal file
173
GSAP-ATTESTOR-DESIGN.md
Normal 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 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/<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
|
||||
```
|
||||
3
Makefile
3
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
|
||||
|
||||
|
|
|
|||
136
cmd/gsap-attestor/attestor.go
Normal file
136
cmd/gsap-attestor/attestor.go
Normal 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
|
||||
}
|
||||
399
cmd/gsap-attestor/attestor_test.go
Normal file
399
cmd/gsap-attestor/attestor_test.go
Normal 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
46
cmd/gsap-attestor/main.go
Normal 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
55
cmd/gsap-attestor/proc.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
52
pkg/gsap/selectors.go
Normal file
52
pkg/gsap/selectors.go
Normal 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
101
pkg/gsap/selectors_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue