From 693c98a1bca41b679e81a98e94ac786c612d86bf Mon Sep 17 00:00:00 2001 From: Maksim An Date: Tue, 30 Jun 2026 16:13:15 -0700 Subject: [PATCH] gcs-sidecar: stage amdsnppspapi.dll into CWCOW container security-context dir Confidential WCOW workloads need the AMD SNP PSP API DLL (amdsnppspapi.dll) to fetch SNP attestation reports, but it only exists in the UVM's System32 and cannot be bind-mounted as a single file. Copy the DLL from the UVM's System32 into each confidential container's existing security-context directory and append that directory (as seen from inside the container) to the container PATH so it is discoverable via LoadLibrary. Staging happens after security policy enforcement, consistent with the existing UVM_SECURITY_CONTEXT_DIR injection, and is a no-op when the DLL is absent (e.g. non-SNP UVMs). WriteSecurityContextDir now returns the created directory path so the sidecar can stage additional files into it; the Linux GCS call site is updated accordingly. Adds unit tests for stageDLL and appendToPathEnv. Signed-off-by: Maksim An Assisted-by: Claude Opus 4.8 --- internal/gcs-sidecar/handlers.go | 91 ++++++++++- internal/gcs-sidecar/stage_dll_test.go | 158 +++++++++++++++++++ internal/guest/runtime/hcsv2/uvm.go | 2 +- pkg/securitypolicy/securitypolicy_options.go | 21 ++- 4 files changed, 262 insertions(+), 10 deletions(-) create mode 100644 internal/gcs-sidecar/stage_dll_test.go diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index f5a7c48d5e..b203519303 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -4,6 +4,7 @@ package bridge import ( + "context" "encoding/hex" "encoding/json" "fmt" @@ -15,6 +16,7 @@ import ( "github.com/Microsoft/go-winio/pkg/guid" "github.com/Microsoft/hcsshim/hcn" "github.com/Microsoft/hcsshim/internal/bridgeutils/commonutils" + "github.com/Microsoft/hcsshim/internal/copyfile" "github.com/Microsoft/hcsshim/internal/fsformatter" "github.com/Microsoft/hcsshim/internal/gcs/prot" hcsschema "github.com/Microsoft/hcsshim/internal/hcs/schema2" @@ -28,7 +30,9 @@ import ( "github.com/Microsoft/hcsshim/pkg/annotations" "github.com/Microsoft/hcsshim/pkg/cimfs" "github.com/Microsoft/hcsshim/pkg/securitypolicy" + "github.com/opencontainers/runtime-spec/specs-go" "github.com/pkg/errors" + "golang.org/x/sys/windows" ) const ( @@ -36,6 +40,10 @@ const ( hivesDirName = "Hives" devPathFormat = "\\\\.\\PHYSICALDRIVE%d" UVMContainerID = "00000000-0000-0000-0000-000000000000" + // amdSnpPspDLLName is the AMD SNP PSP API DLL used to fetch SNP attestation + // reports. It is staged from the UVM's System32 into each confidential + // container's security-context directory so workloads can load it. + amdSnpPspDLLName = "amdsnppspapi.dll" ) // - Handler functions handle the incoming message requests. It @@ -153,9 +161,20 @@ func (b *Bridge) createContainer(req *request) (err error) { }() if oci.ParseAnnotationsBool(ctx, spec.Annotations, annotations.WCOWSecurityPolicyEnv, true) { - if err := b.hostState.securityOptions.WriteSecurityContextDir(&spec); err != nil { + securityContextDir, err := b.hostState.securityOptions.WriteSecurityContextDir(&spec) + if err != nil { return fmt.Errorf("failed to write security context dir: %w", err) } + + // Stage the AMD SNP PSP API DLL into the container's security-context + // directory so the workload can fetch SNP attestation reports. This + // happens after security policy enforcement, consistent with the + // UVM_SECURITY_CONTEXT_DIR env injection done by WriteSecurityContextDir. + if securityContextDir != "" { + if err := stageSnpPspDLL(ctx, &spec, securityContextDir); err != nil { + return fmt.Errorf("failed to stage %s: %w", amdSnpPspDLLName, err) + } + } cwcowHostedSystemConfig.Spec = spec } @@ -201,6 +220,76 @@ func (b *Bridge) createContainer(req *request) (err error) { return nil } +// stageSnpPspDLL copies the AMD SNP PSP API DLL from the UVM's System32 into the +// container's security-context directory and adds that directory (as seen from +// inside the container) to the container's PATH so the DLL is discoverable via +// LoadLibrary by name. If the DLL is not present in the UVM (e.g. a non-SNP +// UVM), staging is skipped without error. +func stageSnpPspDLL(ctx context.Context, spec *specs.Spec, securityContextDir string) error { + sysDir, err := windows.GetSystemDirectory() + if err != nil { + return fmt.Errorf("failed to get system directory: %w", err) + } + + srcPath := filepath.Join(sysDir, amdSnpPspDLLName) + // The security-context directory is created under the container's root + // volume, which is surfaced as C:\ inside the container. + containerCtxDir := filepath.Join(`C:\`, filepath.Base(securityContextDir)) + + staged, err := stageDLL(ctx, spec, srcPath, securityContextDir, containerCtxDir) + if err != nil { + return err + } + if staged { + log.G(ctx).Debugf("staged %s and added %s to container PATH", amdSnpPspDLLName, containerCtxDir) + } else { + log.G(ctx).Debugf("%s not found in %s; skipping staging", amdSnpPspDLLName, sysDir) + } + return nil +} + +// stageDLL copies the DLL at srcPath into dstDir and, when copied, appends +// containerDir (the directory as seen from inside the container) to the +// container's PATH. If the source DLL does not exist it is a no-op and returns +// false without error, so callers can tolerate environments where the DLL is +// not present. +func stageDLL(ctx context.Context, spec *specs.Spec, srcPath, dstDir, containerDir string) (bool, error) { + if spec == nil || spec.Process == nil { + return false, fmt.Errorf("cannot stage %s: spec.Process is nil", filepath.Base(srcPath)) + } + + if _, err := os.Stat(srcPath); err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("failed to stat %s: %w", srcPath, err) + } + + dstPath := filepath.Join(dstDir, filepath.Base(srcPath)) + if err := copyfile.CopyFile(ctx, srcPath, dstPath, true); err != nil { + return false, fmt.Errorf("failed to copy %s to %s: %w", srcPath, dstPath, err) + } + + spec.Process.Env = appendToPathEnv(spec.Process.Env, containerDir) + return true, nil +} + +// appendToPathEnv appends dir to the existing PATH entry in env, preserving the +// original key casing. If no PATH entry exists, one is created. +func appendToPathEnv(env []string, dir string) []string { + for i, e := range env { + if k, v, ok := strings.Cut(e, "="); ok && strings.EqualFold(k, "PATH") { + if v == "" { + env[i] = k + "=" + dir + } else { + env[i] = k + "=" + v + ";" + dir + } + return env + } + } + return append(env, "PATH="+dir) +} + // processParamEnvToOCIEnv converts an Environment field from ProcessParameters // (a map from environment variable to value) into an array of environment // variable assignments (where each is in the form "=") which diff --git a/internal/gcs-sidecar/stage_dll_test.go b/internal/gcs-sidecar/stage_dll_test.go new file mode 100644 index 0000000000..55a0f87046 --- /dev/null +++ b/internal/gcs-sidecar/stage_dll_test.go @@ -0,0 +1,158 @@ +//go:build windows +// +build windows + +package bridge + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + specs "github.com/opencontainers/runtime-spec/specs-go" +) + +func TestAppendToPathEnv(t *testing.T) { + for _, tc := range []struct { + name string + env []string + dir string + want []string + }{ + { + name: "appends to existing PATH", + env: []string{"FOO=bar", `PATH=C:\Windows;C:\Windows\System32`}, + dir: `C:\sec`, + want: []string{"FOO=bar", `PATH=C:\Windows;C:\Windows\System32;C:\sec`}, + }, + { + name: "creates PATH when absent", + env: []string{"FOO=bar"}, + dir: `C:\sec`, + want: []string{"FOO=bar", `PATH=C:\sec`}, + }, + { + name: "handles empty PATH value", + env: []string{"PATH="}, + dir: `C:\sec`, + want: []string{`PATH=C:\sec`}, + }, + { + name: "is case insensitive and preserves original key casing", + env: []string{`Path=C:\Windows`}, + dir: `C:\sec`, + want: []string{`Path=C:\Windows;C:\sec`}, + }, + { + name: "creates PATH on empty env", + env: nil, + dir: `C:\sec`, + want: []string{`PATH=C:\sec`}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got := appendToPathEnv(tc.env, tc.dir) + if len(got) != len(tc.want) { + t.Fatalf("length mismatch: got %v, want %v", got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("env[%d] = %q, want %q", i, got[i], tc.want[i]) + } + } + }) + } +} + +func TestStageDLL_CopiesAndUpdatesPath(t *testing.T) { + srcDir := t.TempDir() + dstDir := t.TempDir() + + contents := []byte("fake-dll-bytes") + srcPath := filepath.Join(srcDir, amdSnpPspDLLName) + if err := os.WriteFile(srcPath, contents, 0644); err != nil { + t.Fatalf("failed to write source dll: %v", err) + } + + spec := &specs.Spec{Process: &specs.Process{Env: []string{`PATH=C:\Windows`}}} + containerDir := `C:\security-context-abc` + + staged, err := stageDLL(context.Background(), spec, srcPath, dstDir, containerDir) + if err != nil { + t.Fatalf("stageDLL returned error: %v", err) + } + if !staged { + t.Fatal("expected staged to be true") + } + + // The DLL should be copied into dstDir with identical contents. + dstPath := filepath.Join(dstDir, amdSnpPspDLLName) + got, err := os.ReadFile(dstPath) + if err != nil { + t.Fatalf("failed to read staged dll: %v", err) + } + if !bytes.Equal(got, contents) { + t.Errorf("staged dll contents = %q, want %q", got, contents) + } + + // The container-visible directory should be appended to PATH. + want := `PATH=C:\Windows;` + containerDir + if spec.Process.Env[0] != want { + t.Errorf("PATH = %q, want %q", spec.Process.Env[0], want) + } +} + +func TestStageDLL_MissingSourceIsNoOp(t *testing.T) { + dstDir := t.TempDir() + srcPath := filepath.Join(t.TempDir(), amdSnpPspDLLName) // does not exist + + originalEnv := []string{`PATH=C:\Windows`} + spec := &specs.Spec{Process: &specs.Process{Env: append([]string(nil), originalEnv...)}} + + staged, err := stageDLL(context.Background(), spec, srcPath, dstDir, `C:\security-context-abc`) + if err != nil { + t.Fatalf("stageDLL returned error: %v", err) + } + if staged { + t.Fatal("expected staged to be false when source is missing") + } + + // No file should have been written to dstDir. + if entries, err := os.ReadDir(dstDir); err != nil { + t.Fatalf("failed to read dstDir: %v", err) + } else if len(entries) != 0 { + t.Errorf("expected dstDir to be empty, found %d entries", len(entries)) + } + + // PATH should be unchanged. + if len(spec.Process.Env) != len(originalEnv) || spec.Process.Env[0] != originalEnv[0] { + t.Errorf("env = %v, want unchanged %v", spec.Process.Env, originalEnv) + } +} + +func TestStageDLL_NilSpecOrProcess(t *testing.T) { + srcDir := t.TempDir() + srcPath := filepath.Join(srcDir, amdSnpPspDLLName) + if err := os.WriteFile(srcPath, []byte("fake-dll-bytes"), 0644); err != nil { + t.Fatalf("failed to write source dll: %v", err) + } + + for _, tc := range []struct { + name string + spec *specs.Spec + }{ + {name: "nil spec", spec: nil}, + {name: "nil process", spec: &specs.Spec{}}, + } { + t.Run(tc.name, func(t *testing.T) { + staged, err := stageDLL(context.Background(), tc.spec, srcPath, t.TempDir(), `C:\security-context-abc`) + if err == nil { + t.Fatal("expected an error, got nil") + } + if staged { + t.Error("expected staged to be false") + } + }) + } +} diff --git a/internal/guest/runtime/hcsv2/uvm.go b/internal/guest/runtime/hcsv2/uvm.go index 513fe00acf..4eb6c4c54d 100644 --- a/internal/guest/runtime/hcsv2/uvm.go +++ b/internal/guest/runtime/hcsv2/uvm.go @@ -722,7 +722,7 @@ func (h *Host) CreateContainer(ctx context.Context, id string, settings *prot.VM } if oci.ParseAnnotationsBool(ctx, settings.OCISpecification.Annotations, annotations.LCOWSecurityPolicyEnv, true) { - if err := h.securityOptions.WriteSecurityContextDir(settings.OCISpecification); err != nil { + if _, err := h.securityOptions.WriteSecurityContextDir(settings.OCISpecification); err != nil { return nil, fmt.Errorf("failed to write security context dir: %w", err) } } diff --git a/pkg/securitypolicy/securitypolicy_options.go b/pkg/securitypolicy/securitypolicy_options.go index cf993780cd..88537e8a03 100644 --- a/pkg/securitypolicy/securitypolicy_options.go +++ b/pkg/securitypolicy/securitypolicy_options.go @@ -191,39 +191,43 @@ func writeFileInDir(dir string, filename string, data []byte, perm os.FileMode) // containing the files is exposed via UVM_SECURITY_CONTEXT_DIR env var. // It may be an error to have a security policy but not expose it to the // container as in that case it can never be checked as correct by a verifier. -func (s *SecurityOptions) WriteSecurityContextDir(spec *specs.Spec) error { +// +// On success it returns the path (in the UVM/guest namespace) of the created +// security context directory, or an empty string if no directory was created +// because there was nothing to write. +func (s *SecurityOptions) WriteSecurityContextDir(spec *specs.Spec) (string, error) { encodedPolicy := s.PolicyEnforcer.EncodedSecurityPolicy() hostAMDCert := spec.Annotations[annotations.WCOWHostAMDCertificate] if len(encodedPolicy) > 0 || len(hostAMDCert) > 0 || len(s.UvmReferenceInfo) > 0 || len(s.UvmHashEnvelopeReferenceInfo) > 0 { // Use os.MkdirTemp to make sure that the directory is unique. securityContextDir, err := os.MkdirTemp(spec.Root.Path, SecurityContextDirTemplate) if err != nil { - return fmt.Errorf("failed to create security context directory: %w", err) + return "", fmt.Errorf("failed to create security context directory: %w", err) } // Make sure that files inside directory are readable if err := os.Chmod(securityContextDir, 0755); err != nil { - return fmt.Errorf("failed to chmod security context directory: %w", err) + return "", fmt.Errorf("failed to chmod security context directory: %w", err) } if len(encodedPolicy) > 0 { if err := writeFileInDir(securityContextDir, PolicyFilename, []byte(encodedPolicy), 0777); err != nil { - return fmt.Errorf("failed to write security policy: %w", err) + return "", fmt.Errorf("failed to write security policy: %w", err) } } if len(s.UvmReferenceInfo) > 0 { if err := writeFileInDir(securityContextDir, ReferenceInfoFilename, []byte(s.UvmReferenceInfo), 0777); err != nil { - return fmt.Errorf("failed to write UVM reference info: %w", err) + return "", fmt.Errorf("failed to write UVM reference info: %w", err) } } if len(s.UvmHashEnvelopeReferenceInfo) > 0 { if err := writeFileInDir(securityContextDir, HashEnvelopeReferenceInfoFilename, []byte(s.UvmHashEnvelopeReferenceInfo), 0777); err != nil { - return fmt.Errorf("failed to write UVM hash envelope reference info: %w", err) + return "", fmt.Errorf("failed to write UVM hash envelope reference info: %w", err) } } if len(hostAMDCert) > 0 { if err := writeFileInDir(securityContextDir, HostAMDCertFilename, []byte(hostAMDCert), 0777); err != nil { - return fmt.Errorf("failed to write host AMD certificate: %w", err) + return "", fmt.Errorf("failed to write host AMD certificate: %w", err) } } @@ -231,6 +235,7 @@ func (s *SecurityOptions) WriteSecurityContextDir(spec *specs.Spec) error { secCtxEnv := fmt.Sprintf("UVM_SECURITY_CONTEXT_DIR=%s", containerCtxDir) spec.Process.Env = append(spec.Process.Env, secCtxEnv) + return securityContextDir, nil } - return nil + return "", nil }