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