fix(security): plugin supply chain hardening — SAFE-T1102 (closes #768)

fix(security): plugin supply chain hardening — SAFE-T1102 (issue #768)
This commit is contained in:
molecule-ai[bot] 2026-04-17 19:04:04 +00:00 committed by GitHub
commit bfc6e56aa5
3 changed files with 138 additions and 4 deletions

View File

@ -4,6 +4,8 @@ import (
"archive/tar"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
@ -108,6 +110,10 @@ func dirSize(dir string, limit int64) (int64, error) {
// gin.Context; the handler just decodes into this shape.
type installRequest struct {
Source string `json:"source"`
// SHA256 is an optional hex-encoded SHA-256 of the plugin's plugin.yaml.
// When present, resolveAndStage verifies the fetched content matches
// before allowing the install to proceed (SAFE-T1102 supply-chain hardening).
SHA256 string `json:"sha256,omitempty"`
}
// stageResult bundles the outputs of resolveAndStage for the caller.
@ -151,6 +157,20 @@ func (h *PluginsHandler) resolveAndStage(ctx context.Context, req installRequest
}
}
// Pinned-ref enforcement for github:// sources (SAFE-T1102).
// An unpinned spec (no #<tag/sha> suffix) installs from a mutable
// default-branch tip whose content can change silently between an
// audit and the actual install. Require explicit pinning unless the
// operator opts in via PLUGIN_ALLOW_UNPINNED=true.
if source.Scheme == "github" && !strings.Contains(source.Spec, "#") {
if os.Getenv("PLUGIN_ALLOW_UNPINNED") != "true" {
return nil, newHTTPErr(http.StatusUnprocessableEntity, gin.H{
"error": `unpinned github source: append a tag or commit SHA (e.g. "github://owner/repo#v1.2.0"). Set PLUGIN_ALLOW_UNPINNED=true to override`,
"source": source.Raw(),
})
}
}
stagedDir, err := os.MkdirTemp("", "molecule-plugin-fetch-*")
if err != nil {
return nil, newHTTPErr(http.StatusInternalServerError, gin.H{"error": "failed to create staging dir"})
@ -189,6 +209,32 @@ func (h *PluginsHandler) resolveAndStage(ctx context.Context, req installRequest
"source": source.Raw(),
})
}
// SHA-256 content integrity check (SAFE-T1102).
// If the caller pinned a hash, verify it against the staged plugin.yaml.
// A mismatch means the fetched content differs from what was audited —
// abort rather than silently install an unexpected plugin.
if req.SHA256 != "" {
manifestPath := filepath.Join(stagedDir, "plugin.yaml")
manifestData, readErr := os.ReadFile(manifestPath)
if readErr != nil {
cleanup()
return nil, newHTTPErr(http.StatusUnprocessableEntity, gin.H{
"error": "sha256 check failed: plugin.yaml not found in staged plugin",
"source": source.Raw(),
})
}
sum := sha256.Sum256(manifestData)
got := hex.EncodeToString(sum[:])
if !strings.EqualFold(got, req.SHA256) {
cleanup()
return nil, newHTTPErr(http.StatusUnprocessableEntity, gin.H{
"error": fmt.Sprintf("sha256 mismatch: expected %s, got %s", req.SHA256, got),
"source": source.Raw(),
})
}
}
return &stageResult{StagedDir: stagedDir, PluginName: pluginName, Source: source}, nil
}

View File

@ -4,6 +4,8 @@ import (
"archive/tar"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
@ -505,6 +507,92 @@ func TestResolveAndStage_LocalSchemePathTraversal(t *testing.T) {
assertHTTPErrStatus(t, err, http.StatusBadRequest, "local path traversal")
}
// ==================== supply-chain hardening (SAFE-T1102) ====================
// TestPluginInstall_SHA256Mismatch_AbortsInstall verifies that when the caller
// provides a sha256 field that does not match the fetched plugin.yaml, the
// install is aborted with 422 Unprocessable Entity and the staging dir is cleaned up.
func TestPluginInstall_SHA256Mismatch_AbortsInstall(t *testing.T) {
beforeCount := tempDirCount(t)
h := NewPluginsHandler(t.TempDir(), nil, nil).WithSourceResolver(&stubResolver{
scheme: "stub",
name: "my-plugin",
content: "name: my-plugin\nversion: 1.0.0\n",
})
_, err := h.resolveAndStage(context.Background(), installRequest{
Source: "stub://my-plugin",
SHA256: "0000000000000000000000000000000000000000000000000000000000000000", // wrong
})
assertHTTPErrStatus(t, err, http.StatusUnprocessableEntity, "sha256 mismatch")
afterCount := tempDirCount(t)
if afterCount > beforeCount {
t.Errorf("SHA256 mismatch left %d orphaned staging dir(s)", afterCount-beforeCount)
}
}
// TestPluginInstall_SHA256Match_Succeeds verifies that resolveAndStage succeeds
// when the caller supplies the correct SHA-256 of the fetched plugin.yaml.
func TestPluginInstall_SHA256Match_Succeeds(t *testing.T) {
content := "name: my-plugin\nversion: 1.0.0\n"
sum := sha256.Sum256([]byte(content))
correctHash := hex.EncodeToString(sum[:])
h := NewPluginsHandler(t.TempDir(), nil, nil).WithSourceResolver(&stubResolver{
scheme: "stub",
name: "my-plugin",
content: content,
})
result, err := h.resolveAndStage(context.Background(), installRequest{
Source: "stub://my-plugin",
SHA256: correctHash,
})
if err != nil {
t.Fatalf("expected success when sha256 matches, got: %v", err)
}
defer os.RemoveAll(result.StagedDir)
if result.PluginName != "my-plugin" {
t.Errorf("expected PluginName 'my-plugin', got %q", result.PluginName)
}
}
// TestPluginInstall_UnpinnedRef_Rejected verifies that a github:// spec without
// a #<ref> suffix is rejected with 422 unless PLUGIN_ALLOW_UNPINNED=true.
func TestPluginInstall_UnpinnedRef_Rejected(t *testing.T) {
t.Setenv("PLUGIN_ALLOW_UNPINNED", "") // ensure the guard is active
h := NewPluginsHandler(t.TempDir(), nil, nil).WithSourceResolver(&stubResolver{
scheme: "github",
name: "my-plugin",
content: "name: my-plugin\n",
})
_, err := h.resolveAndStage(context.Background(), installRequest{
Source: "github://owner/repo", // no #ref — must be rejected
})
assertHTTPErrStatus(t, err, http.StatusUnprocessableEntity, "unpinned ref rejected")
}
// TestPluginInstall_PinnedRef_Accepted verifies that a github:// spec that
// includes a #<ref> suffix passes the pinned-ref guard and completes normally.
func TestPluginInstall_PinnedRef_Accepted(t *testing.T) {
h := NewPluginsHandler(t.TempDir(), nil, nil).WithSourceResolver(&stubResolver{
scheme: "github",
name: "my-plugin",
content: "name: my-plugin\n",
})
result, err := h.resolveAndStage(context.Background(), installRequest{
Source: "github://owner/repo#v1.0.0", // pinned — must be accepted
})
if err != nil {
t.Fatalf("expected success for pinned ref, got: %v", err)
}
defer os.RemoveAll(result.StagedDir)
if result.PluginName != "my-plugin" {
t.Errorf("expected PluginName 'my-plugin', got %q", result.PluginName)
}
}
// ==================== helpers ====================
// assertHTTPErrStatus is a test helper that checks err is a *httpErr with

View File

@ -1271,16 +1271,16 @@ func TestPluginDownload_GithubSchemeStreamsTarball(t *testing.T) {
{Key: "name", Value: "remote-plugin"},
}
req := httptest.NewRequest("GET",
"/workspaces/X/plugins/remote-plugin/download?source=github://acme/remote-plugin", nil)
req.URL.RawQuery = "source=github%3A%2F%2Facme%2Fremote-plugin"
"/workspaces/X/plugins/remote-plugin/download?source=github%3A%2F%2Facme%2Fremote-plugin%23v1.0.0", nil)
req.URL.RawQuery = "source=github%3A%2F%2Facme%2Fremote-plugin%23v1.0.0"
c.Request = req
h.Download(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := w.Header().Get("X-Plugin-Source"); got != "github://acme/remote-plugin" {
t.Errorf("X-Plugin-Source: got %q, want github://acme/remote-plugin", got)
if got := w.Header().Get("X-Plugin-Source"); got != "github://acme/remote-plugin#v1.0.0" {
t.Errorf("X-Plugin-Source: got %q, want github://acme/remote-plugin#v1.0.0", got)
}
// Decode + verify the tarball contains the resolver's files