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 }