mirror of https://github.com/helm/helm
[HIP-0026] Plugin packaging, signing, and verification (#31176)
* Plugin packaging, signing and verification Signed-off-by: Scott Rigby <scott@r6by.com> * wrap keyring read error with more explicit message Co-authored-by: Jesse Simpson <jesse.simpson36@gmail.com> Signed-off-by: Scott Rigby <scott@r6by.com> * skip unnecessary check Co-authored-by: Evans Mungai <mbuevans@gmail.com> Signed-off-by: Scott Rigby <scott@r6by.com> * Change behavior for installing plugin with missing .prov file (now warns and continues instead of failing) Signed-off-by: Scott Rigby <scott@r6by.com> * Add comprehensive plugin verification tests - Test missing .prov files (warns but continues) - Test invalid .prov file formats (fails verification) - Test hash mismatches in .prov files (fails verification) - Test .prov file access errors (fails appropriately) - Test directory plugins don't support verification - Test installation without verification enabled (succeeds) - Test with valid .prov files (fails on empty keyring as expected) --------- Signed-off-by: Scott Rigby <scott@r6by.com> Co-authored-by: Jesse Simpson <jesse.simpson36@gmail.com> Co-authored-by: Evans Mungai <mbuevans@gmail.com>pull/13430/merge
parent
9eafbc53df
commit
9ea35da0d0
@ -0,0 +1,195 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package installer // import "helm.sh/helm/v4/internal/plugin/installer"
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
securejoin "github.com/cyphar/filepath-securejoin"
|
||||
)
|
||||
|
||||
// TarGzExtractor extracts gzip compressed tar archives
|
||||
type TarGzExtractor struct{}
|
||||
|
||||
// Extractor provides an interface for extracting archives
|
||||
type Extractor interface {
|
||||
Extract(buffer *bytes.Buffer, targetDir string) error
|
||||
}
|
||||
|
||||
// Extractors contains a map of suffixes and matching implementations of extractor to return
|
||||
var Extractors = map[string]Extractor{
|
||||
".tar.gz": &TarGzExtractor{},
|
||||
".tgz": &TarGzExtractor{},
|
||||
}
|
||||
|
||||
// Convert a media type to an extractor extension.
|
||||
//
|
||||
// This should be refactored in Helm 4, combined with the extension-based mechanism.
|
||||
func mediaTypeToExtension(mt string) (string, bool) {
|
||||
switch strings.ToLower(mt) {
|
||||
case "application/gzip", "application/x-gzip", "application/x-tgz", "application/x-gtar":
|
||||
return ".tgz", true
|
||||
case "application/octet-stream":
|
||||
// Generic binary type - we'll need to check the URL suffix
|
||||
return "", false
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// NewExtractor creates a new extractor matching the source file name
|
||||
func NewExtractor(source string) (Extractor, error) {
|
||||
for suffix, extractor := range Extractors {
|
||||
if strings.HasSuffix(source, suffix) {
|
||||
return extractor, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no extractor implemented yet for %s", source)
|
||||
}
|
||||
|
||||
// cleanJoin resolves dest as a subpath of root.
|
||||
//
|
||||
// This function runs several security checks on the path, generating an error if
|
||||
// the supplied `dest` looks suspicious or would result in dubious behavior on the
|
||||
// filesystem.
|
||||
//
|
||||
// cleanJoin assumes that any attempt by `dest` to break out of the CWD is an attempt
|
||||
// to be malicious. (If you don't care about this, use the securejoin-filepath library.)
|
||||
// It will emit an error if it detects paths that _look_ malicious, operating on the
|
||||
// assumption that we don't actually want to do anything with files that already
|
||||
// appear to be nefarious.
|
||||
//
|
||||
// - The character `:` is considered illegal because it is a separator on UNIX and a
|
||||
// drive designator on Windows.
|
||||
// - The path component `..` is considered suspicions, and therefore illegal
|
||||
// - The character \ (backslash) is treated as a path separator and is converted to /.
|
||||
// - Beginning a path with a path separator is illegal
|
||||
// - Rudimentary symlink protects are offered by SecureJoin.
|
||||
func cleanJoin(root, dest string) (string, error) {
|
||||
|
||||
// On Windows, this is a drive separator. On UNIX-like, this is the path list separator.
|
||||
// In neither case do we want to trust a TAR that contains these.
|
||||
if strings.Contains(dest, ":") {
|
||||
return "", errors.New("path contains ':', which is illegal")
|
||||
}
|
||||
|
||||
// The Go tar library does not convert separators for us.
|
||||
// We assume here, as we do elsewhere, that `\\` means a Windows path.
|
||||
dest = strings.ReplaceAll(dest, "\\", "/")
|
||||
|
||||
// We want to alert the user that something bad was attempted. Cleaning it
|
||||
// is not a good practice.
|
||||
if slices.Contains(strings.Split(dest, "/"), "..") {
|
||||
return "", errors.New("path contains '..', which is illegal")
|
||||
}
|
||||
|
||||
// If a path is absolute, the creator of the TAR is doing something shady.
|
||||
if path.IsAbs(dest) {
|
||||
return "", errors.New("path is absolute, which is illegal")
|
||||
}
|
||||
|
||||
// SecureJoin will do some cleaning, as well as some rudimentary checking of symlinks.
|
||||
// The directory needs to be cleaned prior to passing to SecureJoin or the location may end up
|
||||
// being wrong or returning an error. This was introduced in v0.4.0.
|
||||
root = filepath.Clean(root)
|
||||
newpath, err := securejoin.SecureJoin(root, dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.ToSlash(newpath), nil
|
||||
}
|
||||
|
||||
// Extract extracts compressed archives
|
||||
//
|
||||
// Implements Extractor.
|
||||
func (g *TarGzExtractor) Extract(buffer *bytes.Buffer, targetDir string) error {
|
||||
uncompressedStream, err := gzip.NewReader(buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tarReader := tar.NewReader(uncompressedStream)
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path, err := cleanJoin(targetDir, header.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(path, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
// Ensure parent directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
outFile, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(outFile, tarReader); err != nil {
|
||||
outFile.Close()
|
||||
return err
|
||||
}
|
||||
outFile.Close()
|
||||
// We don't want to process these extension header files.
|
||||
case tar.TypeXGlobalHeader, tar.TypeXHeader:
|
||||
continue
|
||||
default:
|
||||
return fmt.Errorf("unknown type: %b in %s", header.Typeflag, header.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// stripPluginName is a helper that relies on some sort of convention for plugin name (plugin-name-<version>)
|
||||
func stripPluginName(name string) string {
|
||||
var strippedName string
|
||||
for suffix := range Extractors {
|
||||
if strings.HasSuffix(name, suffix) {
|
||||
strippedName = strings.TrimSuffix(name, suffix)
|
||||
break
|
||||
}
|
||||
}
|
||||
re := regexp.MustCompile(`(.*)-[0-9]+\..*`)
|
||||
return re.ReplaceAllString(strippedName, `$1`)
|
||||
}
|
@ -0,0 +1,421 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package installer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"helm.sh/helm/v4/internal/plugin"
|
||||
"helm.sh/helm/v4/internal/test/ensure"
|
||||
)
|
||||
|
||||
func TestInstallWithOptions_VerifyMissingProvenance(t *testing.T) {
|
||||
ensure.HelmHome(t)
|
||||
|
||||
// Create a temporary plugin tarball without .prov file
|
||||
pluginDir := createTestPluginDir(t)
|
||||
pluginTgz := createTarballFromPluginDir(t, pluginDir)
|
||||
defer os.Remove(pluginTgz)
|
||||
|
||||
// Create local installer
|
||||
installer, err := NewLocalInstaller(pluginTgz)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create installer: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(installer.Path())
|
||||
|
||||
// Capture stderr to check warning message
|
||||
oldStderr := os.Stderr
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stderr = w
|
||||
|
||||
// Install with verification enabled (should warn but succeed)
|
||||
result, err := InstallWithOptions(installer, Options{Verify: true, Keyring: "dummy"})
|
||||
|
||||
// Restore stderr and read captured output
|
||||
w.Close()
|
||||
os.Stderr = oldStderr
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
output := buf.String()
|
||||
|
||||
// Should succeed with nil result (no verification performed)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected installation to succeed despite missing .prov file, got error: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil verification result when .prov file is missing, got: %+v", result)
|
||||
}
|
||||
|
||||
// Should contain warning message
|
||||
expectedWarning := "WARNING: No provenance file found for plugin"
|
||||
if !strings.Contains(output, expectedWarning) {
|
||||
t.Errorf("Expected warning message '%s' in output, got: %s", expectedWarning, output)
|
||||
}
|
||||
|
||||
// Plugin should be installed
|
||||
if _, err := os.Stat(installer.Path()); os.IsNotExist(err) {
|
||||
t.Errorf("Plugin should be installed at %s", installer.Path())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWithOptions_VerifyWithValidProvenance(t *testing.T) {
|
||||
ensure.HelmHome(t)
|
||||
|
||||
// Create a temporary plugin tarball with valid .prov file
|
||||
pluginDir := createTestPluginDir(t)
|
||||
pluginTgz := createTarballFromPluginDir(t, pluginDir)
|
||||
|
||||
provFile := pluginTgz + ".prov"
|
||||
createProvFile(t, provFile, pluginTgz, "")
|
||||
defer os.Remove(provFile)
|
||||
|
||||
// Create keyring with test key (empty for testing)
|
||||
keyring := createTestKeyring(t)
|
||||
defer os.Remove(keyring)
|
||||
|
||||
// Create local installer
|
||||
installer, err := NewLocalInstaller(pluginTgz)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create installer: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(installer.Path())
|
||||
|
||||
// Install with verification enabled
|
||||
// This will fail signature verification but pass hash validation
|
||||
result, err := InstallWithOptions(installer, Options{Verify: true, Keyring: keyring})
|
||||
|
||||
// Should fail due to invalid signature (empty keyring) but we test that it gets past the hash check
|
||||
if err == nil {
|
||||
t.Fatalf("Expected installation to fail with empty keyring")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "plugin verification failed") {
|
||||
t.Errorf("Expected plugin verification failed error, got: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil verification result when verification fails, got: %+v", result)
|
||||
}
|
||||
|
||||
// Plugin should not be installed due to verification failure
|
||||
if _, err := os.Stat(installer.Path()); !os.IsNotExist(err) {
|
||||
t.Errorf("Plugin should not be installed when verification fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWithOptions_VerifyWithInvalidProvenance(t *testing.T) {
|
||||
ensure.HelmHome(t)
|
||||
|
||||
// Create a temporary plugin tarball with invalid .prov file
|
||||
pluginDir := createTestPluginDir(t)
|
||||
pluginTgz := createTarballFromPluginDir(t, pluginDir)
|
||||
defer os.Remove(pluginTgz)
|
||||
|
||||
provFile := pluginTgz + ".prov"
|
||||
createProvFileInvalidFormat(t, provFile)
|
||||
defer os.Remove(provFile)
|
||||
|
||||
// Create keyring with test key
|
||||
keyring := createTestKeyring(t)
|
||||
defer os.Remove(keyring)
|
||||
|
||||
// Create local installer
|
||||
installer, err := NewLocalInstaller(pluginTgz)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create installer: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(installer.Path())
|
||||
|
||||
// Install with verification enabled (should fail)
|
||||
result, err := InstallWithOptions(installer, Options{Verify: true, Keyring: keyring})
|
||||
|
||||
// Should fail with verification error
|
||||
if err == nil {
|
||||
t.Fatalf("Expected installation with invalid .prov file to fail")
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil verification result when verification fails, got: %+v", result)
|
||||
}
|
||||
|
||||
// Should contain verification failure message
|
||||
expectedError := "plugin verification failed"
|
||||
if !strings.Contains(err.Error(), expectedError) {
|
||||
t.Errorf("Expected error message '%s', got: %s", expectedError, err.Error())
|
||||
}
|
||||
|
||||
// Plugin should not be installed
|
||||
if _, err := os.Stat(installer.Path()); !os.IsNotExist(err) {
|
||||
t.Errorf("Plugin should not be installed when verification fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWithOptions_NoVerifyRequested(t *testing.T) {
|
||||
ensure.HelmHome(t)
|
||||
|
||||
// Create a temporary plugin tarball without .prov file
|
||||
pluginDir := createTestPluginDir(t)
|
||||
pluginTgz := createTarballFromPluginDir(t, pluginDir)
|
||||
defer os.Remove(pluginTgz)
|
||||
|
||||
// Create local installer
|
||||
installer, err := NewLocalInstaller(pluginTgz)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create installer: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(installer.Path())
|
||||
|
||||
// Install without verification (should succeed without any verification)
|
||||
result, err := InstallWithOptions(installer, Options{Verify: false})
|
||||
|
||||
// Should succeed with no verification
|
||||
if err != nil {
|
||||
t.Fatalf("Expected installation without verification to succeed, got error: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil verification result when verification is disabled, got: %+v", result)
|
||||
}
|
||||
|
||||
// Plugin should be installed
|
||||
if _, err := os.Stat(installer.Path()); os.IsNotExist(err) {
|
||||
t.Errorf("Plugin should be installed at %s", installer.Path())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWithOptions_VerifyDirectoryNotSupported(t *testing.T) {
|
||||
ensure.HelmHome(t)
|
||||
|
||||
// Create a directory-based plugin (not an archive)
|
||||
pluginDir := createTestPluginDir(t)
|
||||
|
||||
// Create local installer for directory
|
||||
installer, err := NewLocalInstaller(pluginDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create installer: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(installer.Path())
|
||||
|
||||
// Install with verification should fail (directories don't support verification)
|
||||
result, err := InstallWithOptions(installer, Options{Verify: true, Keyring: "dummy"})
|
||||
|
||||
// Should fail with verification not supported error
|
||||
if err == nil {
|
||||
t.Fatalf("Expected installation to fail with verification not supported error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--verify is only supported for plugin tarballs") {
|
||||
t.Errorf("Expected verification not supported error, got: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil verification result when verification fails, got: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWithOptions_VerifyMismatchedProvenance(t *testing.T) {
|
||||
ensure.HelmHome(t)
|
||||
|
||||
// Create plugin tarball
|
||||
pluginDir := createTestPluginDir(t)
|
||||
pluginTgz := createTarballFromPluginDir(t, pluginDir)
|
||||
defer os.Remove(pluginTgz)
|
||||
|
||||
provFile := pluginTgz + ".prov"
|
||||
// Create provenance file with wrong hash (for a different file)
|
||||
createProvFile(t, provFile, pluginTgz, "sha256:wronghash")
|
||||
defer os.Remove(provFile)
|
||||
|
||||
// Create keyring with test key
|
||||
keyring := createTestKeyring(t)
|
||||
defer os.Remove(keyring)
|
||||
|
||||
// Create local installer
|
||||
installer, err := NewLocalInstaller(pluginTgz)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create installer: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(installer.Path())
|
||||
|
||||
// Install with verification should fail due to hash mismatch
|
||||
result, err := InstallWithOptions(installer, Options{Verify: true, Keyring: keyring})
|
||||
|
||||
// Should fail with verification error
|
||||
if err == nil {
|
||||
t.Fatalf("Expected installation to fail with hash mismatch")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "plugin verification failed") {
|
||||
t.Errorf("Expected plugin verification failed error, got: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil verification result when verification fails, got: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWithOptions_VerifyProvenanceAccessError(t *testing.T) {
|
||||
ensure.HelmHome(t)
|
||||
|
||||
// Create plugin tarball
|
||||
pluginDir := createTestPluginDir(t)
|
||||
pluginTgz := createTarballFromPluginDir(t, pluginDir)
|
||||
defer os.Remove(pluginTgz)
|
||||
|
||||
// Create a .prov file but make it inaccessible (simulate permission error)
|
||||
provFile := pluginTgz + ".prov"
|
||||
if err := os.WriteFile(provFile, []byte("test"), 0000); err != nil {
|
||||
t.Fatalf("Failed to create inaccessible provenance file: %v", err)
|
||||
}
|
||||
defer os.Remove(provFile)
|
||||
|
||||
// Create keyring
|
||||
keyring := createTestKeyring(t)
|
||||
defer os.Remove(keyring)
|
||||
|
||||
// Create local installer
|
||||
installer, err := NewLocalInstaller(pluginTgz)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create installer: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(installer.Path())
|
||||
|
||||
// Install with verification should fail due to access error
|
||||
result, err := InstallWithOptions(installer, Options{Verify: true, Keyring: keyring})
|
||||
|
||||
// Should fail with access error (either at stat level or during verification)
|
||||
if err == nil {
|
||||
t.Fatalf("Expected installation to fail with provenance file access error")
|
||||
}
|
||||
// The error could be either "failed to access provenance file" or "plugin verification failed"
|
||||
// depending on when the permission error occurs
|
||||
if !strings.Contains(err.Error(), "failed to access provenance file") &&
|
||||
!strings.Contains(err.Error(), "plugin verification failed") {
|
||||
t.Errorf("Expected provenance file access or verification error, got: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil verification result when verification fails, got: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for test setup
|
||||
|
||||
func createTestPluginDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
// Create temporary directory with plugin structure
|
||||
tmpDir := t.TempDir()
|
||||
pluginDir := filepath.Join(tmpDir, "test-plugin")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create plugin directory: %v", err)
|
||||
}
|
||||
|
||||
// Create plugin.yaml using the standardized v1 format
|
||||
pluginYaml := `apiVersion: v1
|
||||
name: test-plugin
|
||||
type: cli/v1
|
||||
runtime: subprocess
|
||||
version: 1.0.0
|
||||
runtimeConfig:
|
||||
platformCommand:
|
||||
- command: echo`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(pluginYaml), 0644); err != nil {
|
||||
t.Fatalf("Failed to create plugin.yaml: %v", err)
|
||||
}
|
||||
|
||||
return pluginDir
|
||||
}
|
||||
|
||||
func createTarballFromPluginDir(t *testing.T, pluginDir string) string {
|
||||
t.Helper()
|
||||
|
||||
// Create tarball using the plugin package helper
|
||||
tmpDir := filepath.Dir(pluginDir)
|
||||
tgzPath := filepath.Join(tmpDir, "test-plugin-1.0.0.tgz")
|
||||
tarFile, err := os.Create(tgzPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create tarball file: %v", err)
|
||||
}
|
||||
defer tarFile.Close()
|
||||
|
||||
if err := plugin.CreatePluginTarball(pluginDir, "test-plugin", tarFile); err != nil {
|
||||
t.Fatalf("Failed to create tarball: %v", err)
|
||||
}
|
||||
|
||||
return tgzPath
|
||||
}
|
||||
|
||||
func createProvFile(t *testing.T, provFile, pluginTgz, hash string) {
|
||||
t.Helper()
|
||||
|
||||
var hashStr string
|
||||
if hash == "" {
|
||||
// Calculate actual hash of the tarball for realistic testing
|
||||
data, err := os.ReadFile(pluginTgz)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read tarball for hashing: %v", err)
|
||||
}
|
||||
hashSum := sha256.Sum256(data)
|
||||
hashStr = fmt.Sprintf("sha256:%x", hashSum)
|
||||
} else {
|
||||
// Use provided hash (could be wrong for testing)
|
||||
hashStr = hash
|
||||
}
|
||||
|
||||
// Create properly formatted provenance file with specified hash
|
||||
provContent := fmt.Sprintf(`-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA256
|
||||
|
||||
name: test-plugin
|
||||
version: 1.0.0
|
||||
description: Test plugin for verification
|
||||
files:
|
||||
test-plugin-1.0.0.tgz: %s
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
Version: GnuPG v1
|
||||
|
||||
iQEcBAEBCAAGBQJktest...
|
||||
-----END PGP SIGNATURE-----
|
||||
`, hashStr)
|
||||
if err := os.WriteFile(provFile, []byte(provContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create provenance file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createProvFileInvalidFormat(t *testing.T, provFile string) {
|
||||
t.Helper()
|
||||
|
||||
// Create an invalid provenance file (not PGP signed format)
|
||||
invalidProv := "This is not a valid PGP signed message"
|
||||
if err := os.WriteFile(provFile, []byte(invalidProv), 0644); err != nil {
|
||||
t.Fatalf("Failed to create invalid provenance file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createTestKeyring(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
// Create a temporary keyring file
|
||||
tmpDir := t.TempDir()
|
||||
keyringPath := filepath.Join(tmpDir, "pubring.gpg")
|
||||
|
||||
// Create empty keyring for testing
|
||||
if err := os.WriteFile(keyringPath, []byte{}, 0644); err != nil {
|
||||
t.Fatalf("Failed to create test keyring: %v", err)
|
||||
}
|
||||
|
||||
return keyringPath
|
||||
}
|
@ -0,0 +1,166 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"sigs.k8s.io/yaml"
|
||||
|
||||
"helm.sh/helm/v4/pkg/provenance"
|
||||
)
|
||||
|
||||
// SignPlugin signs a plugin using the SHA256 hash of the tarball.
|
||||
//
|
||||
// This is used when packaging and signing a plugin from a tarball file.
|
||||
// It creates a signature that includes the tarball hash and plugin metadata,
|
||||
// allowing verification of the original tarball later.
|
||||
func SignPlugin(tarballPath string, signer *provenance.Signatory) (string, error) {
|
||||
// Extract plugin metadata from tarball
|
||||
pluginMeta, err := extractPluginMetadata(tarballPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to extract plugin metadata: %w", err)
|
||||
}
|
||||
|
||||
// Marshal plugin metadata to YAML bytes
|
||||
metadataBytes, err := yaml.Marshal(pluginMeta)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal plugin metadata: %w", err)
|
||||
}
|
||||
|
||||
// Use the generic provenance signing function
|
||||
return signer.ClearSign(tarballPath, metadataBytes)
|
||||
}
|
||||
|
||||
// extractPluginMetadata extracts plugin metadata from a tarball
|
||||
func extractPluginMetadata(tarballPath string) (*Metadata, error) {
|
||||
f, err := os.Open(tarballPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
return ExtractPluginMetadataFromReader(f)
|
||||
}
|
||||
|
||||
// ExtractPluginMetadataFromReader extracts plugin metadata from a tarball reader
|
||||
func ExtractPluginMetadataFromReader(r io.Reader) (*Metadata, error) {
|
||||
gzr, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer gzr.Close()
|
||||
|
||||
tr := tar.NewReader(gzr)
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Look for plugin.yaml file
|
||||
if filepath.Base(header.Name) == "plugin.yaml" {
|
||||
data, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the plugin metadata
|
||||
metadata, err := loadMetadata(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New("plugin.yaml not found in tarball")
|
||||
}
|
||||
|
||||
// parsePluginMessageBlock parses a signed message block to extract plugin metadata and checksums
|
||||
func parsePluginMessageBlock(data []byte) (*Metadata, *provenance.SumCollection, error) {
|
||||
sc := &provenance.SumCollection{}
|
||||
|
||||
// We only need the checksums for verification, not the full metadata
|
||||
if err := provenance.ParseMessageBlock(data, nil, sc); err != nil {
|
||||
return nil, sc, err
|
||||
}
|
||||
return nil, sc, nil
|
||||
}
|
||||
|
||||
// CreatePluginTarball creates a gzipped tarball from a plugin directory
|
||||
func CreatePluginTarball(sourceDir, pluginName string, w io.Writer) error {
|
||||
gzw := gzip.NewWriter(w)
|
||||
defer gzw.Close()
|
||||
|
||||
tw := tar.NewWriter(gzw)
|
||||
defer tw.Close()
|
||||
|
||||
// Use the plugin name as the base directory in the tarball
|
||||
baseDir := pluginName
|
||||
|
||||
// Walk the directory tree
|
||||
return filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create header
|
||||
header, err := tar.FileInfoHeader(info, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update the name to be relative to the source directory
|
||||
relPath, err := filepath.Rel(sourceDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Include the base directory name in the tarball
|
||||
header.Name = filepath.Join(baseDir, relPath)
|
||||
|
||||
// Write header
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If it's a regular file, write its content
|
||||
if info.Mode().IsRegular() {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := io.Copy(tw, file); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
@ -0,0 +1,92 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"helm.sh/helm/v4/pkg/provenance"
|
||||
)
|
||||
|
||||
func TestSignPlugin(t *testing.T) {
|
||||
// Create a test plugin directory
|
||||
tempDir := t.TempDir()
|
||||
pluginDir := filepath.Join(tempDir, "test-plugin")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a plugin.yaml file
|
||||
pluginYAML := `apiVersion: v1
|
||||
name: test-plugin
|
||||
type: cli/v1
|
||||
runtime: subprocess
|
||||
version: 1.0.0
|
||||
runtimeConfig:
|
||||
platformCommand:
|
||||
- command: echo`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(pluginYAML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a tarball
|
||||
tarballPath := filepath.Join(tempDir, "test-plugin.tgz")
|
||||
tarFile, err := os.Create(tarballPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CreatePluginTarball(pluginDir, "test-plugin", tarFile); err != nil {
|
||||
tarFile.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
tarFile.Close()
|
||||
|
||||
// Create a test key for signing
|
||||
keyring := "../../pkg/cmd/testdata/helm-test-key.secret"
|
||||
signer, err := provenance.NewFromKeyring(keyring, "helm-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := signer.DecryptKey(func(_ string) ([]byte, error) {
|
||||
return []byte(""), nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Sign the plugin tarball
|
||||
sig, err := SignPlugin(tarballPath, signer)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign plugin: %v", err)
|
||||
}
|
||||
|
||||
// Verify the signature contains the expected content
|
||||
if !strings.Contains(sig, "-----BEGIN PGP SIGNED MESSAGE-----") {
|
||||
t.Error("signature does not contain PGP header")
|
||||
}
|
||||
|
||||
// Verify the tarball hash is in the signature
|
||||
expectedHash, err := provenance.DigestFile(tarballPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The signature should contain the tarball hash
|
||||
if !strings.Contains(sig, "sha256:"+expectedHash) {
|
||||
t.Errorf("signature does not contain expected tarball hash: sha256:%s", expectedHash)
|
||||
}
|
||||
}
|
@ -0,0 +1,178 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/openpgp/clearsign" //nolint
|
||||
|
||||
"helm.sh/helm/v4/pkg/helmpath"
|
||||
)
|
||||
|
||||
// SigningInfo contains information about a plugin's signing status
|
||||
type SigningInfo struct {
|
||||
// Status can be:
|
||||
// - "local dev": Plugin is a symlink (development mode)
|
||||
// - "unsigned": No provenance file found
|
||||
// - "invalid provenance": Provenance file is malformed
|
||||
// - "mismatched provenance": Provenance file does not match the installed tarball
|
||||
// - "signed": Valid signature exists for the installed tarball
|
||||
Status string
|
||||
IsSigned bool // True if plugin has a valid signature (even if not verified against keyring)
|
||||
}
|
||||
|
||||
// GetPluginSigningInfo returns signing information for an installed plugin
|
||||
func GetPluginSigningInfo(metadata Metadata) (*SigningInfo, error) {
|
||||
pluginName := metadata.Name
|
||||
pluginDir := helmpath.DataPath("plugins", pluginName)
|
||||
|
||||
// Check if plugin directory exists
|
||||
fi, err := os.Lstat(pluginDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugin %s not found: %w", pluginName, err)
|
||||
}
|
||||
|
||||
// Check if it's a symlink (local development)
|
||||
if fi.Mode()&os.ModeSymlink != 0 {
|
||||
return &SigningInfo{
|
||||
Status: "local dev",
|
||||
IsSigned: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Find the exact tarball file for this plugin
|
||||
pluginsDir := helmpath.DataPath("plugins")
|
||||
tarballPath := filepath.Join(pluginsDir, fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version))
|
||||
if _, err := os.Stat(tarballPath); err != nil {
|
||||
return &SigningInfo{
|
||||
Status: "unsigned",
|
||||
IsSigned: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Check for .prov file associated with the tarball
|
||||
provFile := tarballPath + ".prov"
|
||||
provData, err := os.ReadFile(provFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &SigningInfo{
|
||||
Status: "unsigned",
|
||||
IsSigned: false,
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read provenance file: %w", err)
|
||||
}
|
||||
|
||||
// Parse the provenance file to check validity
|
||||
block, _ := clearsign.Decode(provData)
|
||||
if block == nil {
|
||||
return &SigningInfo{
|
||||
Status: "invalid provenance",
|
||||
IsSigned: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Check if provenance matches the actual tarball
|
||||
blockContent := string(block.Plaintext)
|
||||
if !validateProvenanceHash(blockContent, tarballPath) {
|
||||
return &SigningInfo{
|
||||
Status: "mismatched provenance",
|
||||
IsSigned: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// We have a provenance file that is valid for this plugin
|
||||
// Without a keyring, we can't verify the signature, but we know:
|
||||
// 1. A .prov file exists
|
||||
// 2. It's a valid clearsigned document (cryptographically signed)
|
||||
// 3. The provenance contains valid checksums
|
||||
return &SigningInfo{
|
||||
Status: "signed",
|
||||
IsSigned: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateProvenanceHash(blockContent string, tarballPath string) bool {
|
||||
// Parse provenance to get the expected hash
|
||||
_, sums, err := parsePluginMessageBlock([]byte(blockContent))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must have file checksums
|
||||
if len(sums.Files) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Calculate actual hash of the tarball
|
||||
actualHash, err := calculateFileHash(tarballPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the actual hash matches the expected hash in the provenance
|
||||
for filename, expectedHash := range sums.Files {
|
||||
if strings.Contains(filename, filepath.Base(tarballPath)) && expectedHash == actualHash {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// calculateFileHash calculates the SHA256 hash of a file
|
||||
func calculateFileHash(filePath string) (string, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("sha256:%x", hasher.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// GetSigningInfoForPlugins returns signing info for multiple plugins
|
||||
func GetSigningInfoForPlugins(plugins []Plugin) map[string]*SigningInfo {
|
||||
result := make(map[string]*SigningInfo)
|
||||
|
||||
for _, p := range plugins {
|
||||
m := p.Metadata()
|
||||
|
||||
info, err := GetPluginSigningInfo(m)
|
||||
if err != nil {
|
||||
// If there's an error, treat as unsigned
|
||||
result[m.Name] = &SigningInfo{
|
||||
Status: "unknown",
|
||||
IsSigned: false,
|
||||
}
|
||||
} else {
|
||||
result[m.Name] = info
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
@ -0,0 +1,72 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"helm.sh/helm/v4/pkg/provenance"
|
||||
)
|
||||
|
||||
// VerifyPlugin verifies a plugin tarball against a signature.
|
||||
//
|
||||
// This function verifies that a plugin tarball has a valid provenance file
|
||||
// and that the provenance file is signed by a trusted entity.
|
||||
func VerifyPlugin(pluginPath, keyring string) (*provenance.Verification, error) {
|
||||
// Verify the plugin path exists
|
||||
fi, err := os.Stat(pluginPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Only support tarball verification
|
||||
if fi.IsDir() {
|
||||
return nil, errors.New("directory verification not supported - only plugin tarballs can be verified")
|
||||
}
|
||||
|
||||
// Verify it's a tarball
|
||||
if !isTarball(pluginPath) {
|
||||
return nil, errors.New("plugin file must be a gzipped tarball (.tar.gz or .tgz)")
|
||||
}
|
||||
|
||||
// Look for provenance file
|
||||
provFile := pluginPath + ".prov"
|
||||
if _, err := os.Stat(provFile); err != nil {
|
||||
return nil, fmt.Errorf("could not find provenance file %s: %w", provFile, err)
|
||||
}
|
||||
|
||||
// Create signatory from keyring
|
||||
sig, err := provenance.NewFromKeyring(keyring, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return verifyPluginTarball(pluginPath, provFile, sig)
|
||||
}
|
||||
|
||||
// verifyPluginTarball verifies a plugin tarball against its signature
|
||||
func verifyPluginTarball(pluginPath, provPath string, sig *provenance.Signatory) (*provenance.Verification, error) {
|
||||
// Reuse chart verification logic from pkg/provenance
|
||||
return sig.Verify(pluginPath, provPath)
|
||||
}
|
||||
|
||||
// isTarball checks if a file has a tarball extension
|
||||
func isTarball(filename string) bool {
|
||||
return filepath.Ext(filename) == ".gz" || filepath.Ext(filename) == ".tgz"
|
||||
}
|
@ -0,0 +1,201 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"helm.sh/helm/v4/pkg/provenance"
|
||||
)
|
||||
|
||||
const testKeyFile = "../../pkg/cmd/testdata/helm-test-key.secret"
|
||||
const testPubFile = "../../pkg/cmd/testdata/helm-test-key.pub"
|
||||
|
||||
const testPluginYAML = `apiVersion: v1
|
||||
name: test-plugin
|
||||
type: cli/v1
|
||||
runtime: subprocess
|
||||
version: 1.0.0
|
||||
runtimeConfig:
|
||||
platformCommand:
|
||||
- command: echo`
|
||||
|
||||
func TestVerifyPlugin(t *testing.T) {
|
||||
// Create a test plugin and sign it
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create plugin directory
|
||||
pluginDir := filepath.Join(tempDir, "verify-test-plugin")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(testPluginYAML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create tarball
|
||||
tarballPath := filepath.Join(tempDir, "verify-test-plugin.tar.gz")
|
||||
tarFile, err := os.Create(tarballPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := CreatePluginTarball(pluginDir, "test-plugin", tarFile); err != nil {
|
||||
tarFile.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
tarFile.Close()
|
||||
|
||||
// Sign the plugin with source directory
|
||||
signer, err := provenance.NewFromKeyring(testKeyFile, "helm-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := signer.DecryptKey(func(_ string) ([]byte, error) {
|
||||
return []byte(""), nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sig, err := SignPlugin(tarballPath, signer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Write the signature to .prov file
|
||||
provFile := tarballPath + ".prov"
|
||||
if err := os.WriteFile(provFile, []byte(sig), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Now verify the plugin
|
||||
verification, err := VerifyPlugin(tarballPath, testPubFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to verify plugin: %v", err)
|
||||
}
|
||||
|
||||
// Check verification results
|
||||
if verification.SignedBy == nil {
|
||||
t.Error("SignedBy is nil")
|
||||
}
|
||||
|
||||
if verification.FileName != "verify-test-plugin.tar.gz" {
|
||||
t.Errorf("Expected filename 'verify-test-plugin.tar.gz', got %s", verification.FileName)
|
||||
}
|
||||
|
||||
if verification.FileHash == "" {
|
||||
t.Error("FileHash is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPluginBadSignature(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create a plugin tarball
|
||||
pluginDir := filepath.Join(tempDir, "bad-plugin")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(testPluginYAML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tarballPath := filepath.Join(tempDir, "bad-plugin.tar.gz")
|
||||
tarFile, err := os.Create(tarballPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := CreatePluginTarball(pluginDir, "test-plugin", tarFile); err != nil {
|
||||
tarFile.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
tarFile.Close()
|
||||
|
||||
// Create a bad signature (just some text)
|
||||
badSig := `-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA512
|
||||
|
||||
This is not a real signature
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
InvalidSignatureData
|
||||
|
||||
-----END PGP SIGNATURE-----`
|
||||
|
||||
provFile := tarballPath + ".prov"
|
||||
if err := os.WriteFile(provFile, []byte(badSig), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Try to verify - should fail
|
||||
_, err = VerifyPlugin(tarballPath, testPubFile)
|
||||
if err == nil {
|
||||
t.Error("Expected verification to fail with bad signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPluginMissingProvenance(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
tarballPath := filepath.Join(tempDir, "no-prov.tar.gz")
|
||||
|
||||
// Create a minimal tarball
|
||||
if err := os.WriteFile(tarballPath, []byte("dummy"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Try to verify without .prov file
|
||||
_, err := VerifyPlugin(tarballPath, testPubFile)
|
||||
if err == nil {
|
||||
t.Error("Expected verification to fail without provenance file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPluginDirectory(t *testing.T) {
|
||||
// Create a test plugin directory
|
||||
tempDir := t.TempDir()
|
||||
pluginDir := filepath.Join(tempDir, "test-plugin")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a plugin.yaml file
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(testPluginYAML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Attempt to verify the directory - should fail
|
||||
_, err := VerifyPlugin(pluginDir, testPubFile)
|
||||
if err == nil {
|
||||
t.Error("Expected directory verification to fail, but it succeeded")
|
||||
}
|
||||
|
||||
expectedError := "directory verification not supported"
|
||||
if !containsString(err.Error(), expectedError) {
|
||||
t.Errorf("Expected error to contain %q, got %q", expectedError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) &&
|
||||
(s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
|
||||
strings.Contains(s, substr)))
|
||||
}
|
@ -0,0 +1,209 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
"helm.sh/helm/v4/internal/plugin"
|
||||
"helm.sh/helm/v4/pkg/cmd/require"
|
||||
"helm.sh/helm/v4/pkg/provenance"
|
||||
)
|
||||
|
||||
const pluginPackageDesc = `
|
||||
This command packages a Helm plugin directory into a tarball.
|
||||
|
||||
By default, the command will generate a provenance file signed with a PGP key.
|
||||
This ensures the plugin can be verified after installation.
|
||||
|
||||
Use --sign=false to skip signing (not recommended for distribution).
|
||||
`
|
||||
|
||||
type pluginPackageOptions struct {
|
||||
sign bool
|
||||
keyring string
|
||||
key string
|
||||
passphraseFile string
|
||||
pluginPath string
|
||||
destination string
|
||||
}
|
||||
|
||||
func newPluginPackageCmd(out io.Writer) *cobra.Command {
|
||||
o := &pluginPackageOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "package [PATH]",
|
||||
Short: "package a plugin directory into a plugin archive",
|
||||
Long: pluginPackageDesc,
|
||||
Args: require.ExactArgs(1),
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
o.pluginPath = args[0]
|
||||
return o.run(out)
|
||||
},
|
||||
}
|
||||
|
||||
f := cmd.Flags()
|
||||
f.BoolVar(&o.sign, "sign", true, "use a PGP private key to sign this plugin")
|
||||
f.StringVar(&o.key, "key", "", "name of the key to use when signing. Used if --sign is true")
|
||||
f.StringVar(&o.keyring, "keyring", defaultKeyring(), "location of a public keyring")
|
||||
f.StringVar(&o.passphraseFile, "passphrase-file", "", "location of a file which contains the passphrase for the signing key. Use \"-\" to read from stdin.")
|
||||
f.StringVarP(&o.destination, "destination", "d", ".", "location to write the plugin tarball.")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (o *pluginPackageOptions) run(out io.Writer) error {
|
||||
// Check if the plugin path exists and is a directory
|
||||
fi, err := os.Stat(o.pluginPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
return fmt.Errorf("plugin package only supports directories, not tarballs")
|
||||
}
|
||||
|
||||
// Load and validate plugin metadata
|
||||
pluginMeta, err := plugin.LoadDir(o.pluginPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid plugin directory: %w", err)
|
||||
}
|
||||
|
||||
// Create destination directory if needed
|
||||
if err := os.MkdirAll(o.destination, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If signing is requested, prepare the signer first
|
||||
var signer *provenance.Signatory
|
||||
if o.sign {
|
||||
// Load the signing key
|
||||
signer, err = provenance.NewFromKeyring(o.keyring, o.key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading from keyring: %w", err)
|
||||
}
|
||||
|
||||
// Get passphrase
|
||||
passphraseFetcher := o.promptUser
|
||||
if o.passphraseFile != "" {
|
||||
passphraseFetcher, err = o.passphraseFileFetcher()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt the key
|
||||
if err := signer.DecryptKey(passphraseFetcher); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// User explicitly disabled signing
|
||||
fmt.Fprintf(out, "WARNING: Skipping plugin signing. This is not recommended for plugins intended for distribution.\n")
|
||||
}
|
||||
|
||||
// Now create the tarball (only after signing prerequisites are met)
|
||||
// Use plugin metadata for filename: PLUGIN_NAME-SEMVER.tgz
|
||||
metadata := pluginMeta.Metadata()
|
||||
filename := fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version)
|
||||
tarballPath := filepath.Join(o.destination, filename)
|
||||
|
||||
tarFile, err := os.Create(tarballPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create tarball: %w", err)
|
||||
}
|
||||
defer tarFile.Close()
|
||||
|
||||
if err := plugin.CreatePluginTarball(o.pluginPath, metadata.Name, tarFile); err != nil {
|
||||
os.Remove(tarballPath)
|
||||
return fmt.Errorf("failed to create plugin tarball: %w", err)
|
||||
}
|
||||
tarFile.Close() // Ensure file is closed before signing
|
||||
|
||||
// If signing was requested, sign the tarball
|
||||
if o.sign {
|
||||
// Sign the plugin tarball (not the source directory)
|
||||
sig, err := plugin.SignPlugin(tarballPath, signer)
|
||||
if err != nil {
|
||||
os.Remove(tarballPath)
|
||||
return fmt.Errorf("failed to sign plugin: %w", err)
|
||||
}
|
||||
|
||||
// Write the signature
|
||||
provFile := tarballPath + ".prov"
|
||||
if err := os.WriteFile(provFile, []byte(sig), 0644); err != nil {
|
||||
os.Remove(tarballPath)
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "Successfully signed. Signature written to: %s\n", provFile)
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "Successfully packaged plugin and saved it to: %s\n", tarballPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *pluginPackageOptions) promptUser(name string) ([]byte, error) {
|
||||
fmt.Printf("Password for key %q > ", name)
|
||||
pw, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Println()
|
||||
return pw, err
|
||||
}
|
||||
|
||||
func (o *pluginPackageOptions) passphraseFileFetcher() (provenance.PassphraseFetcher, error) {
|
||||
file, err := openPassphraseFile(o.passphraseFile, os.Stdin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read the entire passphrase
|
||||
passphrase, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Trim any trailing newline characters (both \n and \r\n)
|
||||
passphrase = bytes.TrimRight(passphrase, "\r\n")
|
||||
|
||||
return func(_ string) ([]byte, error) {
|
||||
return passphrase, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
// copied from action.openPassphraseFile
|
||||
// TODO: should we move this to pkg/action so we can reuse the func from there?
|
||||
func openPassphraseFile(passphraseFile string, stdin *os.File) (*os.File, error) {
|
||||
if passphraseFile == "-" {
|
||||
stat, err := stdin.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if (stat.Mode() & os.ModeNamedPipe) == 0 {
|
||||
return nil, errors.New("specified reading passphrase from stdin, without input on stdin")
|
||||
}
|
||||
return stdin, nil
|
||||
}
|
||||
return os.Open(passphraseFile)
|
||||
}
|
@ -0,0 +1,170 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Common plugin.yaml content for v1 format tests
|
||||
const testPluginYAML = `apiVersion: v1
|
||||
name: test-plugin
|
||||
version: 1.0.0
|
||||
type: cli/v1
|
||||
runtime: subprocess
|
||||
config:
|
||||
usage: test-plugin [flags]
|
||||
shortHelp: A test plugin
|
||||
longHelp: A test plugin for testing purposes
|
||||
runtimeConfig:
|
||||
platformCommands:
|
||||
- os: linux
|
||||
command: echo
|
||||
args: ["test"]`
|
||||
|
||||
func TestPluginPackageWithoutSigning(t *testing.T) {
|
||||
// Create a test plugin directory
|
||||
tempDir := t.TempDir()
|
||||
pluginDir := filepath.Join(tempDir, "test-plugin")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a plugin.yaml file
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(testPluginYAML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create package options with sign=false
|
||||
o := &pluginPackageOptions{
|
||||
sign: false, // Explicitly disable signing
|
||||
pluginPath: pluginDir,
|
||||
destination: tempDir,
|
||||
}
|
||||
|
||||
// Run the package command
|
||||
out := &bytes.Buffer{}
|
||||
err := o.run(out)
|
||||
|
||||
// Should succeed without error
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Check that tarball was created with plugin name and version
|
||||
tarballPath := filepath.Join(tempDir, "test-plugin-1.0.0.tgz")
|
||||
if _, err := os.Stat(tarballPath); os.IsNotExist(err) {
|
||||
t.Error("tarball should exist when sign=false")
|
||||
}
|
||||
|
||||
// Check that no .prov file was created
|
||||
provPath := tarballPath + ".prov"
|
||||
if _, err := os.Stat(provPath); !os.IsNotExist(err) {
|
||||
t.Error("provenance file should not exist when sign=false")
|
||||
}
|
||||
|
||||
// Output should contain warning about skipping signing
|
||||
output := out.String()
|
||||
if !strings.Contains(output, "WARNING: Skipping plugin signing") {
|
||||
t.Error("should print warning when signing is skipped")
|
||||
}
|
||||
if !strings.Contains(output, "Successfully packaged") {
|
||||
t.Error("should print success message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginPackageDefaultRequiresSigning(t *testing.T) {
|
||||
// Create a test plugin directory
|
||||
tempDir := t.TempDir()
|
||||
pluginDir := filepath.Join(tempDir, "test-plugin")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a plugin.yaml file
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(testPluginYAML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create package options with default sign=true and invalid keyring
|
||||
o := &pluginPackageOptions{
|
||||
sign: true, // This is now the default
|
||||
keyring: "/non/existent/keyring",
|
||||
pluginPath: pluginDir,
|
||||
destination: tempDir,
|
||||
}
|
||||
|
||||
// Run the package command
|
||||
out := &bytes.Buffer{}
|
||||
err := o.run(out)
|
||||
|
||||
// Should fail because signing is required by default
|
||||
if err == nil {
|
||||
t.Error("expected error when signing fails with default settings")
|
||||
}
|
||||
|
||||
// Check that no tarball was created
|
||||
tarballPath := filepath.Join(tempDir, "test-plugin.tgz")
|
||||
if _, err := os.Stat(tarballPath); !os.IsNotExist(err) {
|
||||
t.Error("tarball should not exist when signing fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginPackageSigningFailure(t *testing.T) {
|
||||
// Create a test plugin directory
|
||||
tempDir := t.TempDir()
|
||||
pluginDir := filepath.Join(tempDir, "test-plugin")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a plugin.yaml file
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(testPluginYAML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create package options with sign flag but invalid keyring
|
||||
o := &pluginPackageOptions{
|
||||
sign: true,
|
||||
keyring: "/non/existent/keyring", // This will cause signing to fail
|
||||
pluginPath: pluginDir,
|
||||
destination: tempDir,
|
||||
}
|
||||
|
||||
// Run the package command
|
||||
out := &bytes.Buffer{}
|
||||
err := o.run(out)
|
||||
|
||||
// Should get an error
|
||||
if err == nil {
|
||||
t.Error("expected error when signing fails, got nil")
|
||||
}
|
||||
|
||||
// Check that no tarball was created
|
||||
tarballPath := filepath.Join(tempDir, "test-plugin.tgz")
|
||||
if _, err := os.Stat(tarballPath); !os.IsNotExist(err) {
|
||||
t.Error("tarball should not exist when signing fails")
|
||||
}
|
||||
|
||||
// Output should not contain success message
|
||||
if bytes.Contains(out.Bytes(), []byte("Successfully packaged")) {
|
||||
t.Error("should not print success message when signing fails")
|
||||
}
|
||||
}
|
@ -0,0 +1,88 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"helm.sh/helm/v4/internal/plugin"
|
||||
"helm.sh/helm/v4/pkg/cmd/require"
|
||||
)
|
||||
|
||||
const pluginVerifyDesc = `
|
||||
This command verifies that a Helm plugin has a valid provenance file,
|
||||
and that the provenance file is signed by a trusted PGP key.
|
||||
|
||||
It supports both:
|
||||
- Plugin tarballs (.tgz or .tar.gz files)
|
||||
- Installed plugin directories
|
||||
|
||||
For installed plugins, use the path shown by 'helm env HELM_PLUGINS' followed
|
||||
by the plugin name. For example:
|
||||
helm plugin verify ~/.local/share/helm/plugins/example-cli
|
||||
|
||||
To generate a signed plugin, use the 'helm plugin package --sign' command.
|
||||
`
|
||||
|
||||
type pluginVerifyOptions struct {
|
||||
keyring string
|
||||
pluginPath string
|
||||
}
|
||||
|
||||
func newPluginVerifyCmd(out io.Writer) *cobra.Command {
|
||||
o := &pluginVerifyOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "verify [PATH]",
|
||||
Short: "verify that a plugin at the given path has been signed and is valid",
|
||||
Long: pluginVerifyDesc,
|
||||
Args: require.ExactArgs(1),
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
o.pluginPath = args[0]
|
||||
return o.run(out)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&o.keyring, "keyring", defaultKeyring(), "keyring containing public keys")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (o *pluginVerifyOptions) run(out io.Writer) error {
|
||||
// Verify the plugin
|
||||
verification, err := plugin.VerifyPlugin(o.pluginPath, o.keyring)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Output verification details
|
||||
for name := range verification.SignedBy.Identities {
|
||||
fmt.Fprintf(out, "Signed by: %v\n", name)
|
||||
}
|
||||
fmt.Fprintf(out, "Using Key With Fingerprint: %X\n", verification.SignedBy.PrimaryKey.Fingerprint)
|
||||
|
||||
// Only show hash for tarballs
|
||||
if verification.FileHash != "" {
|
||||
fmt.Fprintf(out, "Plugin Hash Verified: %s\n", verification.FileHash)
|
||||
} else {
|
||||
fmt.Fprintf(out, "Plugin Metadata Verified: %s\n", verification.FileName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -0,0 +1,264 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"helm.sh/helm/v4/internal/plugin"
|
||||
"helm.sh/helm/v4/internal/test/ensure"
|
||||
)
|
||||
|
||||
func TestPluginVerifyCmd_NoArgs(t *testing.T) {
|
||||
ensure.HelmHome(t)
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
cmd := newPluginVerifyCmd(out)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("expected error when no arguments provided")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires 1 argument") {
|
||||
t.Errorf("expected 'requires 1 argument' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginVerifyCmd_TooManyArgs(t *testing.T) {
|
||||
ensure.HelmHome(t)
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
cmd := newPluginVerifyCmd(out)
|
||||
cmd.SetArgs([]string{"plugin1", "plugin2"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("expected error when too many arguments provided")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires 1 argument") {
|
||||
t.Errorf("expected 'requires 1 argument' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginVerifyCmd_NonexistentFile(t *testing.T) {
|
||||
ensure.HelmHome(t)
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
cmd := newPluginVerifyCmd(out)
|
||||
cmd.SetArgs([]string{"/nonexistent/plugin.tgz"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("expected error when plugin file doesn't exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginVerifyCmd_MissingProvenance(t *testing.T) {
|
||||
ensure.HelmHome(t)
|
||||
|
||||
// Create a plugin tarball without .prov file
|
||||
pluginTgz := createTestPluginTarball(t)
|
||||
defer os.Remove(pluginTgz)
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
cmd := newPluginVerifyCmd(out)
|
||||
cmd.SetArgs([]string{pluginTgz})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("expected error when .prov file is missing")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "could not find provenance file") {
|
||||
t.Errorf("expected 'could not find provenance file' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginVerifyCmd_InvalidProvenance(t *testing.T) {
|
||||
ensure.HelmHome(t)
|
||||
|
||||
// Create a plugin tarball with invalid .prov file
|
||||
pluginTgz := createTestPluginTarball(t)
|
||||
defer os.Remove(pluginTgz)
|
||||
|
||||
// Create invalid .prov file
|
||||
provFile := pluginTgz + ".prov"
|
||||
if err := os.WriteFile(provFile, []byte("invalid provenance"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Remove(provFile)
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
cmd := newPluginVerifyCmd(out)
|
||||
cmd.SetArgs([]string{pluginTgz})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("expected error when .prov file is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginVerifyCmd_DirectoryNotSupported(t *testing.T) {
|
||||
ensure.HelmHome(t)
|
||||
|
||||
// Create a plugin directory
|
||||
pluginDir := createTestPluginDir(t)
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
cmd := newPluginVerifyCmd(out)
|
||||
cmd.SetArgs([]string{pluginDir})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("expected error when verifying directory")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "directory verification not supported") {
|
||||
t.Errorf("expected 'directory verification not supported' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginVerifyCmd_KeyringFlag(t *testing.T) {
|
||||
ensure.HelmHome(t)
|
||||
|
||||
// Create a plugin tarball with .prov file
|
||||
pluginTgz := createTestPluginTarball(t)
|
||||
defer os.Remove(pluginTgz)
|
||||
|
||||
// Create .prov file
|
||||
provFile := pluginTgz + ".prov"
|
||||
createProvFile(t, provFile, pluginTgz, "")
|
||||
defer os.Remove(provFile)
|
||||
|
||||
// Create empty keyring file
|
||||
keyring := createTestKeyring(t)
|
||||
defer os.Remove(keyring)
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
cmd := newPluginVerifyCmd(out)
|
||||
cmd.SetArgs([]string{"--keyring", keyring, pluginTgz})
|
||||
|
||||
// Should fail with keyring error but command parsing should work
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("expected error with empty keyring")
|
||||
}
|
||||
// The important thing is that the keyring flag was parsed and used
|
||||
}
|
||||
|
||||
func TestPluginVerifyOptions_Run_Success(t *testing.T) {
|
||||
// Skip this test as it would require real PGP keys and valid signatures
|
||||
// The core verification logic is thoroughly tested in internal/plugin/verify_test.go
|
||||
t.Skip("Success case requires real PGP keys - core logic tested in internal/plugin/verify_test.go")
|
||||
}
|
||||
|
||||
// Helper functions for test setup
|
||||
|
||||
func createTestPluginDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
// Create temporary directory with plugin structure
|
||||
tmpDir := t.TempDir()
|
||||
pluginDir := filepath.Join(tmpDir, "test-plugin")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create plugin directory: %v", err)
|
||||
}
|
||||
|
||||
// Use the same plugin YAML as other cmd tests
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(testPluginYAML), 0644); err != nil {
|
||||
t.Fatalf("Failed to create plugin.yaml: %v", err)
|
||||
}
|
||||
|
||||
return pluginDir
|
||||
}
|
||||
|
||||
func createTestPluginTarball(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
pluginDir := createTestPluginDir(t)
|
||||
|
||||
// Create tarball using the plugin package helper
|
||||
tmpDir := filepath.Dir(pluginDir)
|
||||
tgzPath := filepath.Join(tmpDir, "test-plugin-1.0.0.tgz")
|
||||
tarFile, err := os.Create(tgzPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create tarball file: %v", err)
|
||||
}
|
||||
defer tarFile.Close()
|
||||
|
||||
if err := plugin.CreatePluginTarball(pluginDir, "test-plugin", tarFile); err != nil {
|
||||
t.Fatalf("Failed to create tarball: %v", err)
|
||||
}
|
||||
|
||||
return tgzPath
|
||||
}
|
||||
|
||||
func createProvFile(t *testing.T, provFile, pluginTgz, hash string) {
|
||||
t.Helper()
|
||||
|
||||
var hashStr string
|
||||
if hash == "" {
|
||||
// Calculate actual hash of the tarball
|
||||
data, err := os.ReadFile(pluginTgz)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read tarball for hashing: %v", err)
|
||||
}
|
||||
hashSum := sha256.Sum256(data)
|
||||
hashStr = fmt.Sprintf("sha256:%x", hashSum)
|
||||
} else {
|
||||
// Use provided hash
|
||||
hashStr = hash
|
||||
}
|
||||
|
||||
// Create properly formatted provenance file with specified hash
|
||||
provContent := fmt.Sprintf(`-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA256
|
||||
|
||||
name: test-plugin
|
||||
version: 1.0.0
|
||||
description: Test plugin for verification
|
||||
files:
|
||||
test-plugin-1.0.0.tgz: %s
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
Version: GnuPG v1
|
||||
|
||||
iQEcBAEBCAAGBQJktest...
|
||||
-----END PGP SIGNATURE-----
|
||||
`, hashStr)
|
||||
if err := os.WriteFile(provFile, []byte(provContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create provenance file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createTestKeyring(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
// Create a temporary keyring file
|
||||
tmpDir := t.TempDir()
|
||||
keyringPath := filepath.Join(tmpDir, "pubring.gpg")
|
||||
|
||||
// Create empty keyring for testing
|
||||
if err := os.WriteFile(keyringPath, []byte{}, 0644); err != nil {
|
||||
t.Fatalf("Failed to create test keyring: %v", err)
|
||||
}
|
||||
|
||||
return keyringPath
|
||||
}
|
Loading…
Reference in new issue