github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/secret/set/set.go (about)

     1  package set
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/base64"
     6  	"fmt"
     7  	"io"
     8  	"net/http"
     9  	"os"
    10  	"strings"
    11  
    12  	"github.com/AlecAivazis/survey/v2"
    13  	"github.com/MakeNowJust/heredoc"
    14  	"github.com/ungtb10d/cli/v2/api"
    15  	"github.com/ungtb10d/cli/v2/internal/config"
    16  	"github.com/ungtb10d/cli/v2/internal/ghrepo"
    17  	"github.com/ungtb10d/cli/v2/pkg/cmd/secret/shared"
    18  	"github.com/ungtb10d/cli/v2/pkg/cmdutil"
    19  	"github.com/ungtb10d/cli/v2/pkg/iostreams"
    20  	"github.com/ungtb10d/cli/v2/pkg/prompt"
    21  	"github.com/hashicorp/go-multierror"
    22  	"github.com/joho/godotenv"
    23  	"github.com/spf13/cobra"
    24  	"golang.org/x/crypto/nacl/box"
    25  )
    26  
    27  type SetOptions struct {
    28  	HttpClient func() (*http.Client, error)
    29  	IO         *iostreams.IOStreams
    30  	Config     func() (config.Config, error)
    31  	BaseRepo   func() (ghrepo.Interface, error)
    32  
    33  	RandomOverride func() io.Reader
    34  
    35  	SecretName      string
    36  	OrgName         string
    37  	EnvName         string
    38  	UserSecrets     bool
    39  	Body            string
    40  	DoNotStore      bool
    41  	Visibility      string
    42  	RepositoryNames []string
    43  	EnvFile         string
    44  	Application     string
    45  }
    46  
    47  func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command {
    48  	opts := &SetOptions{
    49  		IO:         f.IOStreams,
    50  		Config:     f.Config,
    51  		HttpClient: f.HttpClient,
    52  	}
    53  
    54  	cmd := &cobra.Command{
    55  		Use:   "set <secret-name>",
    56  		Short: "Create or update secrets",
    57  		Long: heredoc.Doc(`
    58  			Set a value for a secret on one of the following levels:
    59  			- repository (default): available to Actions runs or Dependabot in a repository
    60  			- environment: available to Actions runs for a deployment environment in a repository
    61  			- organization: available to Actions runs, Dependabot, or Codespaces within an organization
    62  			- user: available to Codespaces for your user
    63  
    64  			Organization and user secrets can optionally be restricted to only be available to
    65  			specific repositories.
    66  
    67  			Secret values are locally encrypted before being sent to GitHub.
    68  		`),
    69  		Example: heredoc.Doc(`
    70  			# Paste secret value for the current repository in an interactive prompt
    71  			$ gh secret set MYSECRET
    72  
    73  			# Read secret value from an environment variable
    74  			$ gh secret set MYSECRET --body "$ENV_VALUE"
    75  
    76  			# Read secret value from a file
    77  			$ gh secret set MYSECRET < myfile.txt
    78  
    79  			# Set secret for a deployment environment in the current repository
    80  			$ gh secret set MYSECRET --env myenvironment
    81  
    82  			# Set organization-level secret visible to both public and private repositories
    83  			$ gh secret set MYSECRET --org myOrg --visibility all
    84  
    85  			# Set organization-level secret visible to specific repositories
    86  			$ gh secret set MYSECRET --org myOrg --repos repo1,repo2,repo3
    87  
    88  			# Set user-level secret for Codespaces
    89  			$ gh secret set MYSECRET --user
    90  
    91  			# Set repository-level secret for Dependabot
    92  			$ gh secret set MYSECRET --app dependabot
    93  
    94  			# Set multiple secrets imported from the ".env" file
    95  			$ gh secret set -f .env
    96  		`),
    97  		Args: cobra.MaximumNArgs(1),
    98  		RunE: func(cmd *cobra.Command, args []string) error {
    99  			// support `-R, --repo` override
   100  			opts.BaseRepo = f.BaseRepo
   101  
   102  			if err := cmdutil.MutuallyExclusive("specify only one of `--org`, `--env`, or `--user`", opts.OrgName != "", opts.EnvName != "", opts.UserSecrets); err != nil {
   103  				return err
   104  			}
   105  
   106  			if err := cmdutil.MutuallyExclusive("specify only one of `--body` or `--env-file`", opts.Body != "", opts.EnvFile != ""); err != nil {
   107  				return err
   108  			}
   109  
   110  			if err := cmdutil.MutuallyExclusive("specify only one of `--env-file` or `--no-store`", opts.EnvFile != "", opts.DoNotStore); err != nil {
   111  				return err
   112  			}
   113  
   114  			if len(args) == 0 {
   115  				if !opts.DoNotStore && opts.EnvFile == "" {
   116  					return cmdutil.FlagErrorf("must pass name argument")
   117  				}
   118  			} else {
   119  				opts.SecretName = args[0]
   120  			}
   121  
   122  			if cmd.Flags().Changed("visibility") {
   123  				if opts.OrgName == "" {
   124  					return cmdutil.FlagErrorf("`--visibility` is only supported with `--org`")
   125  				}
   126  
   127  				if opts.Visibility != shared.Selected && len(opts.RepositoryNames) > 0 {
   128  					return cmdutil.FlagErrorf("`--repos` is only supported with `--visibility=selected`")
   129  				}
   130  
   131  				if opts.Visibility == shared.Selected && len(opts.RepositoryNames) == 0 {
   132  					return cmdutil.FlagErrorf("`--repos` list required with `--visibility=selected`")
   133  				}
   134  			} else {
   135  				if len(opts.RepositoryNames) > 0 {
   136  					opts.Visibility = shared.Selected
   137  				}
   138  			}
   139  
   140  			if runF != nil {
   141  				return runF(opts)
   142  			}
   143  
   144  			return setRun(opts)
   145  		},
   146  	}
   147  
   148  	cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "Set `organization` secret")
   149  	cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "Set deployment `environment` secret")
   150  	cmd.Flags().BoolVarP(&opts.UserSecrets, "user", "u", false, "Set a secret for your user")
   151  	cmdutil.StringEnumFlag(cmd, &opts.Visibility, "visibility", "v", shared.Private, []string{shared.All, shared.Private, shared.Selected}, "Set visibility for an organization secret")
   152  	cmd.Flags().StringSliceVarP(&opts.RepositoryNames, "repos", "r", []string{}, "List of `repositories` that can access an organization or user secret")
   153  	cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "The value for the secret (reads from standard input if not specified)")
   154  	cmd.Flags().BoolVar(&opts.DoNotStore, "no-store", false, "Print the encrypted, base64-encoded value instead of storing it on Github")
   155  	cmd.Flags().StringVarP(&opts.EnvFile, "env-file", "f", "", "Load secret names and values from a dotenv-formatted `file`")
   156  	cmdutil.StringEnumFlag(cmd, &opts.Application, "app", "a", "", []string{shared.Actions, shared.Codespaces, shared.Dependabot}, "Set the application for a secret")
   157  
   158  	return cmd
   159  }
   160  
   161  func setRun(opts *SetOptions) error {
   162  	secrets, err := getSecretsFromOptions(opts)
   163  	if err != nil {
   164  		return err
   165  	}
   166  
   167  	c, err := opts.HttpClient()
   168  	if err != nil {
   169  		return fmt.Errorf("could not create http client: %w", err)
   170  	}
   171  	client := api.NewClientFromHTTP(c)
   172  
   173  	orgName := opts.OrgName
   174  	envName := opts.EnvName
   175  
   176  	var host string
   177  	var baseRepo ghrepo.Interface
   178  	if orgName == "" && !opts.UserSecrets {
   179  		baseRepo, err = opts.BaseRepo()
   180  		if err != nil {
   181  			return fmt.Errorf("could not determine base repo: %w", err)
   182  		}
   183  		host = baseRepo.RepoHost()
   184  	} else {
   185  		cfg, err := opts.Config()
   186  		if err != nil {
   187  			return err
   188  		}
   189  		host, _ = cfg.DefaultHost()
   190  	}
   191  
   192  	secretEntity, err := shared.GetSecretEntity(orgName, envName, opts.UserSecrets)
   193  	if err != nil {
   194  		return err
   195  	}
   196  
   197  	secretApp, err := shared.GetSecretApp(opts.Application, secretEntity)
   198  	if err != nil {
   199  		return err
   200  	}
   201  
   202  	if !shared.IsSupportedSecretEntity(secretApp, secretEntity) {
   203  		return fmt.Errorf("%s secrets are not supported for %s", secretEntity, secretApp)
   204  	}
   205  
   206  	var pk *PubKey
   207  	switch secretEntity {
   208  	case shared.Organization:
   209  		pk, err = getOrgPublicKey(client, host, orgName, secretApp)
   210  	case shared.Environment:
   211  		pk, err = getEnvPubKey(client, baseRepo, envName)
   212  	case shared.User:
   213  		pk, err = getUserPublicKey(client, host)
   214  	default:
   215  		pk, err = getRepoPubKey(client, baseRepo, secretApp)
   216  	}
   217  	if err != nil {
   218  		return fmt.Errorf("failed to fetch public key: %w", err)
   219  	}
   220  
   221  	type repoNamesResult struct {
   222  		ids []int64
   223  		err error
   224  	}
   225  	repoNamesC := make(chan repoNamesResult, 1)
   226  	go func() {
   227  		if len(opts.RepositoryNames) == 0 {
   228  			repoNamesC <- repoNamesResult{}
   229  			return
   230  		}
   231  		repositoryIDs, err := mapRepoNamesToIDs(client, host, opts.OrgName, opts.RepositoryNames)
   232  		repoNamesC <- repoNamesResult{
   233  			ids: repositoryIDs,
   234  			err: err,
   235  		}
   236  	}()
   237  
   238  	var repositoryIDs []int64
   239  	if result := <-repoNamesC; result.err == nil {
   240  		repositoryIDs = result.ids
   241  	} else {
   242  		return result.err
   243  	}
   244  
   245  	setc := make(chan setResult)
   246  	for secretKey, secret := range secrets {
   247  		key := secretKey
   248  		value := secret
   249  		go func() {
   250  			setc <- setSecret(opts, pk, host, client, baseRepo, key, value, repositoryIDs, secretApp, secretEntity)
   251  		}()
   252  	}
   253  
   254  	err = nil
   255  	cs := opts.IO.ColorScheme()
   256  	for i := 0; i < len(secrets); i++ {
   257  		result := <-setc
   258  		if result.err != nil {
   259  			err = multierror.Append(err, result.err)
   260  			continue
   261  		}
   262  		if result.encrypted != "" {
   263  			fmt.Fprintln(opts.IO.Out, result.encrypted)
   264  			continue
   265  		}
   266  		if !opts.IO.IsStdoutTTY() {
   267  			continue
   268  		}
   269  		target := orgName
   270  		if opts.UserSecrets {
   271  			target = "your user"
   272  		} else if orgName == "" {
   273  			target = ghrepo.FullName(baseRepo)
   274  		}
   275  		fmt.Fprintf(opts.IO.Out, "%s Set %s secret %s for %s\n", cs.SuccessIcon(), secretApp.Title(), result.key, target)
   276  	}
   277  	return err
   278  }
   279  
   280  type setResult struct {
   281  	key       string
   282  	encrypted string
   283  	err       error
   284  }
   285  
   286  func setSecret(opts *SetOptions, pk *PubKey, host string, client *api.Client, baseRepo ghrepo.Interface, secretKey string, secret []byte, repositoryIDs []int64, app shared.App, entity shared.SecretEntity) (res setResult) {
   287  	orgName := opts.OrgName
   288  	envName := opts.EnvName
   289  	res.key = secretKey
   290  
   291  	decodedPubKey, err := base64.StdEncoding.DecodeString(pk.Key)
   292  	if err != nil {
   293  		res.err = fmt.Errorf("failed to decode public key: %w", err)
   294  		return
   295  	}
   296  	var peersPubKey [32]byte
   297  	copy(peersPubKey[:], decodedPubKey[0:32])
   298  
   299  	var rand io.Reader
   300  	if opts.RandomOverride != nil {
   301  		rand = opts.RandomOverride()
   302  	}
   303  	eBody, err := box.SealAnonymous(nil, secret[:], &peersPubKey, rand)
   304  	if err != nil {
   305  		res.err = fmt.Errorf("failed to encrypt body: %w", err)
   306  		return
   307  	}
   308  
   309  	encoded := base64.StdEncoding.EncodeToString(eBody)
   310  	if opts.DoNotStore {
   311  		res.encrypted = encoded
   312  		return
   313  	}
   314  
   315  	switch entity {
   316  	case shared.Organization:
   317  		err = putOrgSecret(client, host, pk, orgName, opts.Visibility, secretKey, encoded, repositoryIDs, app)
   318  	case shared.Environment:
   319  		err = putEnvSecret(client, pk, baseRepo, envName, secretKey, encoded)
   320  	case shared.User:
   321  		err = putUserSecret(client, host, pk, secretKey, encoded, repositoryIDs)
   322  	default:
   323  		err = putRepoSecret(client, pk, baseRepo, secretKey, encoded, app)
   324  	}
   325  	if err != nil {
   326  		res.err = fmt.Errorf("failed to set secret %q: %w", secretKey, err)
   327  		return
   328  	}
   329  	return
   330  }
   331  
   332  func getSecretsFromOptions(opts *SetOptions) (map[string][]byte, error) {
   333  	secrets := make(map[string][]byte)
   334  
   335  	if opts.EnvFile != "" {
   336  		var r io.Reader
   337  		if opts.EnvFile == "-" {
   338  			defer opts.IO.In.Close()
   339  			r = opts.IO.In
   340  		} else {
   341  			f, err := os.Open(opts.EnvFile)
   342  			if err != nil {
   343  				return nil, fmt.Errorf("failed to open env file: %w", err)
   344  			}
   345  			defer f.Close()
   346  			r = f
   347  		}
   348  		envs, err := godotenv.Parse(r)
   349  		if err != nil {
   350  			return nil, fmt.Errorf("error parsing env file: %w", err)
   351  		}
   352  		if len(envs) == 0 {
   353  			return nil, fmt.Errorf("no secrets found in file")
   354  		}
   355  		for key, value := range envs {
   356  			secrets[key] = []byte(value)
   357  		}
   358  		return secrets, nil
   359  	}
   360  
   361  	body, err := getBody(opts)
   362  	if err != nil {
   363  		return nil, fmt.Errorf("did not understand secret body: %w", err)
   364  	}
   365  	secrets[opts.SecretName] = body
   366  	return secrets, nil
   367  }
   368  
   369  func getBody(opts *SetOptions) ([]byte, error) {
   370  	if opts.Body != "" {
   371  		return []byte(opts.Body), nil
   372  	}
   373  
   374  	if opts.IO.CanPrompt() {
   375  		var bodyInput string
   376  		//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
   377  		err := prompt.SurveyAskOne(&survey.Password{
   378  			Message: "Paste your secret",
   379  		}, &bodyInput)
   380  		if err != nil {
   381  			return nil, err
   382  		}
   383  		fmt.Fprintln(opts.IO.Out)
   384  		return []byte(bodyInput), nil
   385  	}
   386  
   387  	body, err := io.ReadAll(opts.IO.In)
   388  	if err != nil {
   389  		return nil, fmt.Errorf("failed to read from standard input: %w", err)
   390  	}
   391  
   392  	return bytes.TrimRight(body, "\r\n"), nil
   393  }
   394  
   395  func mapRepoNamesToIDs(client *api.Client, host, defaultOwner string, repositoryNames []string) ([]int64, error) {
   396  	repos := make([]ghrepo.Interface, 0, len(repositoryNames))
   397  	for _, repositoryName := range repositoryNames {
   398  		var repo ghrepo.Interface
   399  		if strings.Contains(repositoryName, "/") || defaultOwner == "" {
   400  			var err error
   401  			repo, err = ghrepo.FromFullNameWithHost(repositoryName, host)
   402  			if err != nil {
   403  				return nil, fmt.Errorf("invalid repository name: %w", err)
   404  			}
   405  		} else {
   406  			repo = ghrepo.NewWithHost(defaultOwner, repositoryName, host)
   407  		}
   408  		repos = append(repos, repo)
   409  	}
   410  	repositoryIDs, err := mapRepoToID(client, host, repos)
   411  	if err != nil {
   412  		return nil, fmt.Errorf("failed to look up IDs for repositories %v: %w", repositoryNames, err)
   413  	}
   414  	return repositoryIDs, nil
   415  }