diff --git a/CHANGELOG.md b/CHANGELOG.md index 781d25d..38fc9f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Installed-plugin provenance is recorded on the instance** (spore-plugins#8). + A successful `spawn plugin install` now writes a `spore:plugin:` EC2 tag + (version, content-digest + commit prefixes, and the verification tier reached — + `signature`/`manifest`/`none`) and persists the full resolved provenance into + spored's on-instance plugin state, so an audit can answer "which plugin bytes + are on this box, and how were they verified" from both the AWS control plane + and the instance itself — not just the controller's local record. `spawn plugin + status ` now shows a `Source:` line with the recorded provenance. Both + writes are best-effort and never fail an already-completed install. + +### Fixed +- **Clear error on a cosign legacy-bundle signature.** A plugin release signed + without `--new-bundle-format` produces cosign's legacy bundle, which the + verifier can't parse; it now reports that explicitly instead of a cryptic + protobuf error (spore-plugins#8). + ## [0.86.0] - 2026-07-20 ### Added diff --git a/cmd/plugin.go b/cmd/plugin.go index bf61e00..cd2228b 100644 --- a/cmd/plugin.go +++ b/cmd/plugin.go @@ -251,9 +251,18 @@ func runPluginInstall(ctx context.Context, ref, instance string, cfg map[string] } } fmt.Println("Running remote install steps on the instance...") - if err := remotePluginInstall(ctx, sshHost, spec, resolvedCfg, pushBuf.Values()); err != nil { + if err := remotePluginInstall(ctx, sshHost, spec, resolvedCfg, pushBuf.Values(), prov); err != nil { return fmt.Errorf("remote install: %w", err) } + // Record provenance on the instance's EC2 tags so an audit can answer + // "which plugin bytes are on this box" from the AWS control plane, surviving + // loss of the controller-side local record. Done once the install request + // is accepted (the bytes are on the box and their provenance is known), + // independent of whether the plugin's service later reaches Running — + // matching spored's on-instance state, which also records on failure. + // Best-effort: a tagging failure never fails the install. + recordPluginProvenanceTag(ctx, instance, spec, prov) + if err := waitForPluginReady(ctx, sshHost, spec.Name); err != nil { return err } @@ -264,6 +273,53 @@ func runPluginInstall(ctx context.Context, ref, instance string, cfg map[string] return nil } +// pluginProvenanceTagValue builds the compact EC2-tag value recording a plugin's +// resolved provenance. It stays well under EC2's 256-char tag-value limit: a +// short content-digest prefix, the commit prefix (if pinned), the requested ref, +// and the verification tier reached. +func pluginProvenanceTagValue(spec *plugin.PluginSpec, prov *plugin.Provenance) string { + parts := []string{"version=" + spec.Version} + if prov != nil { + if prov.ContentSHA256 != "" { + parts = append(parts, "sha256="+shortHash(prov.ContentSHA256)) + } + if prov.CommitSHA != "" { + parts = append(parts, "commit="+shortHash(prov.CommitSHA)) + } + switch { + case prov.SignatureVerified: + parts = append(parts, "verify=signature") + case prov.ManifestVerified: + parts = append(parts, "verify=manifest") + default: + parts = append(parts, "verify=none") + } + } + return strings.Join(parts, ";") +} + +// recordPluginProvenanceTag writes a spore:plugin: EC2 tag recording the +// installed plugin's provenance. Best-effort: it resolves the instance to an EC2 +// ID + region and skips silently (with a warning) if that's not possible (e.g. a +// bare hostname not backed by a resolvable spore instance). +func recordPluginProvenanceTag(ctx context.Context, instance string, spec *plugin.PluginSpec, prov *plugin.Provenance) { + inst := resolveInstanceViaSpawn(ctx, instance) + if inst == nil || inst.InstanceID == "" { + return // not an EC2-resolvable instance; the local record still holds provenance + } + client, err := aws.NewClientWithRegion(ctx, inst.Region) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: could not record plugin provenance tag: %v\n", err) + return + } + tags := map[string]string{ + "spore:plugin:" + spec.Name: pluginProvenanceTagValue(spec, prov), + } + if err := client.UpdateInstanceTags(ctx, inst.Region, inst.InstanceID, tags); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not write plugin provenance tag for %s: %v\n", spec.Name, err) + } +} + // saveLocalPluginRecord persists a controller-side record of an installed // plugin so its local deprovision steps can be replayed later. Best-effort: a // failure here is logged but does not fail the install (the plugin is already @@ -470,16 +526,17 @@ func deprovisionAllLocalPlugins(ctx context.Context, instanceKeys ...string) { // remotePluginInstall sends the resolved spec, config, and buffered pushed // values to spored's install endpoint over an SSH tunnel. spored runs the // install asynchronously and returns 202; the outcome is polled separately. -func remotePluginInstall(ctx context.Context, instance string, spec *plugin.PluginSpec, cfg, pushed map[string]string) error { +func remotePluginInstall(ctx context.Context, instance string, spec *plugin.PluginSpec, cfg, pushed map[string]string, prov *plugin.Provenance) error { specYAML, err := yaml.Marshal(spec) if err != nil { return fmt.Errorf("marshal spec: %w", err) } payload, err := json.Marshal(map[string]interface{}{ - "spec": string(specYAML), - "config": cfg, - "pushed": pushed, + "spec": string(specYAML), + "config": cfg, + "pushed": pushed, + "provenance": prov, }) if err != nil { return fmt.Errorf("encode install request: %w", err) @@ -582,6 +639,9 @@ var pluginStatusCmd = &cobra.Command{ if st.LastHealth != nil { fmt.Printf("Health: last OK %s\n", st.LastHealth.Format(time.RFC3339)) } + if p := st.Provenance; p != nil { + fmt.Printf("Source: %s\n", describeProvenance(p)) + } return nil }, } @@ -740,6 +800,7 @@ type pluginStateResponse struct { UpdatedAt time.Time `json:"updated_at"` LastHealth *time.Time `json:"last_health,omitempty"` Error string `json:"error,omitempty"` + Provenance *plugin.Provenance `json:"provenance,omitempty"` } func remotePluginList(ctx context.Context, instance string) ([]*pluginStateResponse, error) { diff --git a/cmd/plugin_provenance_test.go b/cmd/plugin_provenance_test.go new file mode 100644 index 0000000..133a677 --- /dev/null +++ b/cmd/plugin_provenance_test.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/spore-host/spawn/pkg/plugin" +) + +func TestPluginProvenanceTagValue(t *testing.T) { + spec := &plugin.PluginSpec{Name: "rstudio-server", Version: "v1.0.1"} + + cases := []struct { + name string + prov *plugin.Provenance + want []string // substrings that must appear + }{ + { + name: "signed official", + prov: &plugin.Provenance{ + ContentSHA256: strings.Repeat("a", 64), + CommitSHA: strings.Repeat("b", 40), + ManifestVerified: true, + SignatureVerified: true, + }, + want: []string{"version=v1.0.1", "sha256=aaaaaaaaaaaa", "commit=bbbbbbbbbbbb", "verify=signature"}, + }, + { + name: "manifest only (unsigned)", + prov: &plugin.Provenance{ContentSHA256: strings.Repeat("c", 64), ManifestVerified: true}, + want: []string{"verify=manifest"}, + }, + { + name: "unverified", + prov: &plugin.Provenance{ContentSHA256: strings.Repeat("d", 64)}, + want: []string{"verify=none"}, + }, + { + name: "nil provenance still records version", + prov: nil, + want: []string{"version=v1.0.1"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := pluginProvenanceTagValue(spec, tc.prov) + if len(got) > 256 { + t.Errorf("tag value %d chars, exceeds EC2's 256-char limit: %q", len(got), got) + } + for _, sub := range tc.want { + if !strings.Contains(got, sub) { + t.Errorf("tag value %q missing %q", got, sub) + } + } + }) + } +} diff --git a/pkg/plugin/state.go b/pkg/plugin/state.go index 1ac71ee..bb1d8b8 100644 --- a/pkg/plugin/state.go +++ b/pkg/plugin/state.go @@ -55,6 +55,12 @@ type PluginState struct { UpdatedAt time.Time `json:"updated_at"` LastHealth *time.Time `json:"last_health,omitempty"` Error string `json:"error,omitempty"` + // Provenance records where the installed spec came from and how it was + // verified (commit/digest/signature), so an on-instance audit — `spawn plugin + // status` or reading spored's state file — can answer "which bytes are here." + // nil for installs from a controller that didn't supply it (older spawn) or a + // local-file ref with no remote provenance. + Provenance *Provenance `json:"provenance,omitempty"` } // StateStore manages plugin state persistence. diff --git a/pkg/pluginruntime/install_test.go b/pkg/pluginruntime/install_test.go index 02a83c2..8f325f3 100644 --- a/pkg/pluginruntime/install_test.go +++ b/pkg/pluginruntime/install_test.go @@ -60,6 +60,42 @@ func TestInstallWithPushed_SeedsPushedBeforeConfigure(t *testing.T) { } } +// TestInstallWithProvenance_PersistsToState verifies that a resolved Provenance +// handed to the runtime is recorded in the plugin's on-instance state, so an +// audit can read what was installed and how it was verified. +func TestInstallWithProvenance_PersistsToState(t *testing.T) { + rt := newTestRuntime(t) + spec := &plugin.PluginSpec{ + Name: "provrec", + Version: "v1.0.0", + Description: "d", + Remote: plugin.RemoteBlock{Install: []plugin.Step{{Type: "run", Run: "true"}}}, + } + prov := &plugin.Provenance{ + Host: "official", + Name: "provrec", + CommitSHA: "2dda4ab8dc6f734699112473f011e34fbf862f2b", + ContentSHA256: "0bffc628dd64b12920bef7561ff105c0f2ea23b2fe370db3be07cccda99561d0", + ManifestVerified: true, + SignatureVerified: true, + ReleaseTag: "provrec-v1.0.0", + } + + if err := rt.InstallWithProvenance(context.Background(), spec, nil, nil, prov); err != nil { + t.Fatalf("InstallWithProvenance: %v", err) + } + st, err := rt.store.Load("provrec") + if err != nil { + t.Fatalf("load state: %v", err) + } + if st.Provenance == nil { + t.Fatal("state.Provenance = nil, want the recorded provenance") + } + if !st.Provenance.SignatureVerified || st.Provenance.CommitSHA != prov.CommitSHA { + t.Errorf("state.Provenance = %+v, want signature-verified with commit %s", st.Provenance, prov.CommitSHA) + } +} + // TestInstall_MissingPushedParksWaiting confirms the documented limitation: with // no pushed value seeded, a configure step referencing {{ pushed.x }} parks the // plugin at WaitingForPush rather than failing. diff --git a/pkg/pluginruntime/pushapi.go b/pkg/pluginruntime/pushapi.go index 89db88b..6979947 100644 --- a/pkg/pluginruntime/pushapi.go +++ b/pkg/pluginruntime/pushapi.go @@ -116,9 +116,10 @@ func (s *PushAPIServer) authMiddleware(next http.Handler) http.Handler { // spored runs. pushed carries values captured/pushed by the controller's local // provision steps so configure can resolve {{ pushed. }} up front. type installRequest struct { - Spec string `json:"spec"` // plugin.yaml contents - Config map[string]string `json:"config,omitempty"` - Pushed map[string]string `json:"pushed,omitempty"` + Spec string `json:"spec"` // plugin.yaml contents + Config map[string]string `json:"config,omitempty"` + Pushed map[string]string `json:"pushed,omitempty"` + Provenance *plugin.Provenance `json:"provenance,omitempty"` // resolved origin/verification, recorded in on-instance state } // handleInstall receives POST /v1/plugins/install and runs the remote half of a @@ -154,7 +155,7 @@ func (s *PushAPIServer) handleInstall(w http.ResponseWriter, r *http.Request) { baseCtx = context.Background() } go func() { - if err := s.rt.InstallWithPushed(baseCtx, spec, req.Config, req.Pushed); err != nil { + if err := s.rt.InstallWithProvenance(baseCtx, spec, req.Config, req.Pushed, req.Provenance); err != nil { log.Printf("Install error for plugin %s: %v", spec.Name, err) } }() diff --git a/pkg/pluginruntime/runtime.go b/pkg/pluginruntime/runtime.go index 1196c1f..21d552c 100644 --- a/pkg/pluginruntime/runtime.go +++ b/pkg/pluginruntime/runtime.go @@ -64,6 +64,14 @@ func (rt *Runtime) Install(ctx context.Context, spec *plugin.PluginSpec, cfg map // ReceivePush. That path is unused by the current registry plugins and is left // as a known limitation. func (rt *Runtime) InstallWithPushed(ctx context.Context, spec *plugin.PluginSpec, cfg map[string]string, pushed map[string]string) error { + return rt.InstallWithProvenance(ctx, spec, cfg, pushed, nil) +} + +// InstallWithProvenance is InstallWithPushed plus a resolved Provenance record +// (origin + verification tier) that is persisted into the plugin's on-instance +// state, so an audit can read what was installed and how it was verified. prov +// may be nil (local-file ref, or an older controller that didn't supply it). +func (rt *Runtime) InstallWithProvenance(ctx context.Context, spec *plugin.PluginSpec, cfg, pushed map[string]string, prov *plugin.Provenance) error { resolvedCfg, err := spec.ResolvedConfig(cfg) if err != nil { return fmt.Errorf("resolve config: %w", err) @@ -81,6 +89,7 @@ func (rt *Runtime) InstallWithPushed(ctx context.Context, spec *plugin.PluginSpe Config: resolvedCfg, Outputs: make(map[string]string), Pushed: seededPushed, + Provenance: prov, InstalledAt: time.Now(), } if err := rt.store.Save(state); err != nil {