fix(cli): protect Go install credentials
This commit is contained in:
1 parent
3488ab778c
commit
996672a6a7
16 files changed
+1128
-79
No files matched your search
@@ -0,0 +1,212 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testAPIToken = "ely_test_token"
|
||||
|
||||
type fakeSecretTerminal struct {
|
||||
interactive bool
|
||||
answers []string
|
||||
prompts []string
|
||||
}
|
||||
|
||||
func (terminal *fakeSecretTerminal) Interactive() bool {
|
||||
return terminal.interactive
|
||||
}
|
||||
|
||||
func (terminal *fakeSecretTerminal) ReadHidden(prompt string) (string, error) {
|
||||
terminal.prompts = append(terminal.prompts, prompt)
|
||||
if len(terminal.answers) == 0 {
|
||||
return "", os.ErrInvalid
|
||||
}
|
||||
answer := terminal.answers[0]
|
||||
terminal.answers = terminal.answers[1:]
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
func testPrivateKey() string {
|
||||
return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))
|
||||
}
|
||||
|
||||
func writeSecretFile(t *testing.T, path, value string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(value), 0600); err != nil {
|
||||
t.Fatalf("write secret file: %v", err)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
if err := os.Chmod(path, 0600); err != nil {
|
||||
t.Fatalf("restrict secret file: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInstallSecretsUsesHiddenPrompts(t *testing.T) {
|
||||
terminal := &fakeSecretTerminal{
|
||||
interactive: true,
|
||||
answers: []string{testPrivateKey(), testAPIToken},
|
||||
}
|
||||
secrets, err := resolveInstallSecrets(installSecretSources{}, terminal)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve install secrets: %v", err)
|
||||
}
|
||||
if secrets.privateKey != testPrivateKey() || secrets.token != testAPIToken {
|
||||
t.Fatalf("resolved secrets have unexpected values")
|
||||
}
|
||||
wantPrompts := []string{"Private key: ", "API token (optional): "}
|
||||
if strings.Join(terminal.prompts, "|") != strings.Join(wantPrompts, "|") {
|
||||
t.Fatalf("prompts = %q, want %q", terminal.prompts, wantPrompts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInstallSecretsRequiresFileWhenNoninteractive(t *testing.T) {
|
||||
terminal := &fakeSecretTerminal{interactive: false}
|
||||
_, err := resolveInstallSecrets(installSecretSources{}, terminal)
|
||||
if err == nil || !strings.Contains(err.Error(), "--private-key-file <path>") {
|
||||
t.Fatalf("resolve install secrets error = %v", err)
|
||||
}
|
||||
if len(terminal.prompts) != 0 {
|
||||
t.Fatalf("unexpected prompts: %q", terminal.prompts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInstallSecretsReadsCredentialFiles(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
privateKeyPath := filepath.Join(directory, "private.key")
|
||||
tokenPath := filepath.Join(directory, "token")
|
||||
writeSecretFile(t, privateKeyPath, testPrivateKey()+"\r\n")
|
||||
writeSecretFile(t, tokenPath, testAPIToken+"\n")
|
||||
|
||||
secrets, err := resolveInstallSecrets(installSecretSources{
|
||||
privateKeyFile: privateKeyPath,
|
||||
tokenFile: tokenPath,
|
||||
}, &fakeSecretTerminal{})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve install secrets: %v", err)
|
||||
}
|
||||
if secrets.privateKey != testPrivateKey() || secrets.token != testAPIToken {
|
||||
t.Fatalf("resolved secrets have unexpected values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSecretFileRejectsInvalidContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
pattern string
|
||||
}{
|
||||
{name: "empty", content: "", pattern: "is empty"},
|
||||
{name: "multiple lines", content: testPrivateKey() + "\nsecond\n", pattern: "one line"},
|
||||
{name: "nul", content: "key\x00value", pattern: "one line"},
|
||||
{name: "invalid UTF-8", content: string([]byte{0xff}), pattern: "UTF-8"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "private.key")
|
||||
writeSecretFile(t, path, test.content)
|
||||
_, err := readSecretFile(path, "private key")
|
||||
if err == nil || !strings.Contains(err.Error(), test.pattern) {
|
||||
t.Fatalf("read secret file error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSecretFileRejectsOversizedContent(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "private.key")
|
||||
writeSecretFile(t, path, strings.Repeat("a", maxSecretFileBytes+1))
|
||||
_, err := readSecretFile(path, "private key")
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds") {
|
||||
t.Fatalf("read secret file error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSecretFileRejectsSymbolicLink(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
target := filepath.Join(directory, "target.key")
|
||||
link := filepath.Join(directory, "linked.key")
|
||||
writeSecretFile(t, target, testPrivateKey())
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skipf("file symbolic links unavailable: %v", err)
|
||||
}
|
||||
_, err := readSecretFile(link, "private key")
|
||||
if err == nil || !strings.Contains(err.Error(), "regular file") {
|
||||
t.Fatalf("read secret file error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSecretFileRequiresOwnerOnlyPermissions(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX permission contract")
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "private.key")
|
||||
writeSecretFile(t, path, testPrivateKey())
|
||||
if err := os.Chmod(path, 0640); err != nil {
|
||||
t.Fatalf("widen secret file permissions: %v", err)
|
||||
}
|
||||
_, err := readSecretFile(path, "private key")
|
||||
if err == nil || !strings.Contains(err.Error(), "only by its owner") {
|
||||
t.Fatalf("read secret file error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSecretFileDetectsReplacementWhileOpening(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
path := filepath.Join(directory, "private.key")
|
||||
replacement := filepath.Join(directory, "replacement.key")
|
||||
writeSecretFile(t, path, testPrivateKey())
|
||||
writeSecretFile(t, replacement, testPrivateKey())
|
||||
|
||||
_, err := readSecretFileWithOpen(path, "private key", func(name string) (*os.File, error) {
|
||||
if removeErr := os.Remove(path); removeErr != nil {
|
||||
return nil, removeErr
|
||||
}
|
||||
if renameErr := os.Rename(replacement, path); renameErr != nil {
|
||||
return nil, renameErr
|
||||
}
|
||||
return os.Open(name)
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "changed while opening") {
|
||||
t.Fatalf("read secret file error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePrivateKeyRejectsInvalidSeeds(t *testing.T) {
|
||||
tests := []string{"invalid!", base64.RawURLEncoding.EncodeToString([]byte("short"))}
|
||||
for _, privateKey := range tests {
|
||||
if err := validatePrivateKey(privateKey); err == nil {
|
||||
t.Fatalf("validatePrivateKey(%q) succeeded", privateKey)
|
||||
}
|
||||
}
|
||||
if err := validatePrivateKey(testPrivateKey()); err != nil {
|
||||
t.Fatalf("validate private key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectLegacySecretArgumentsRedactsValues(t *testing.T) {
|
||||
tests := [][]string{
|
||||
{"--private-key", "must-not-appear"},
|
||||
{"--private-key=must-not-appear"},
|
||||
{"--token", "must-not-appear"},
|
||||
{"--token=must-not-appear"},
|
||||
}
|
||||
for _, arguments := range tests {
|
||||
err := rejectLegacySecretArguments(arguments)
|
||||
if err == nil {
|
||||
t.Fatalf("rejectLegacySecretArguments(%q) succeeded", arguments)
|
||||
}
|
||||
if strings.Contains(err.Error(), "must-not-appear") {
|
||||
t.Fatalf("legacy secret value leaked in error: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "-file") {
|
||||
t.Fatalf("replacement option missing from error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user