From 5aad28bb67307afb0037558e4a36767eac79e925 Mon Sep 17 00:00:00 2001 From: Katie May <100700631+katiem0@users.noreply.github.com> Date: Fri, 5 May 2023 16:59:12 -0400 Subject: [PATCH] Initial commit --- .github/workflows/release.yml | 14 + .gitignore | 5 + README.md | 201 +++++++++++ cmd/root.go | 26 ++ cmd/secrets/create/create.go | 317 ++++++++++++++++++ cmd/secrets/export/export.go | 513 +++++++++++++++++++++++++++++ cmd/secrets/secrets.go | 22 ++ cmd/variables/create/createvars.go | 261 +++++++++++++++ cmd/variables/export/exportvars.go | 276 ++++++++++++++++ cmd/variables/variables.go | 22 ++ go.mod | 30 ++ go.sum | 81 +++++ internal/data/actions.go | 103 ++++++ internal/data/codespaces.go | 103 ++++++ internal/data/dependabot.go | 103 ++++++ internal/data/general.go | 160 +++++++++ internal/data/importsecrets.go | 118 +++++++ internal/data/importvariable.go | 138 ++++++++ internal/data/variables.go | 49 +++ internal/log/log.go | 24 ++ main.go | 15 + 21 files changed, 2581 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cmd/root.go create mode 100644 cmd/secrets/create/create.go create mode 100644 cmd/secrets/export/export.go create mode 100644 cmd/secrets/secrets.go create mode 100644 cmd/variables/create/createvars.go create mode 100644 cmd/variables/export/exportvars.go create mode 100644 cmd/variables/variables.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/data/actions.go create mode 100644 internal/data/codespaces.go create mode 100644 internal/data/dependabot.go create mode 100644 internal/data/general.go create mode 100644 internal/data/importsecrets.go create mode 100644 internal/data/importvariable.go create mode 100644 internal/data/variables.go create mode 100644 internal/log/log.go create mode 100644 main.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0dfa818 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,14 @@ +name: release +on: + push: + tags: + - "v*" +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: cli/gh-extension-precompile@v1 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..251c69f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/gh-seva +/gh-seva.exe +secrets.csv +report-*.csv +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..da85b79 --- /dev/null +++ b/README.md @@ -0,0 +1,201 @@ +# gh-seva + +A GitHub `gh` [CLI](https://cli.github.com/) extension to list and create Secrets and Variables defined at an Organization level, as well as create webhooks from a file or `source-org` under a new organization. + +## Installation + +1. Install the `gh` CLI - see the [installation](https://github.com/cli/cli#installation) instructions. + +2. Install the extension: + ```sh + gh extension install katiem0/gh-seva + ``` + +For more information: [`gh extension install`](https://cli.github.com/manual/gh_extension_install). + +## Usage + +This extension supports listing and creating secrets and variables between `GitHub.com` and GitHub Enterprise Server, through the use of `--hostname` and `--source-hostname`. + +```sh +$ gh seva -h +Export and Create secrets and variables for an organization and/or repositories. + +Usage: + seva [command] + +Available Commands: + secrets Export and Create secrets for an organization and/or repositories. + variables Export and Create variables for an organization and/or repositories. + +Flags: + --help Show help for command + +Use "seva [command] --help" for more information about a command. +``` + +### Secrets + +The `gh seva secrets` command comprises of two subcommands, `export` and `create`, to access and create Organization level and repository level secrets. + +```sh +$ gh seva secrets -h +Export and Create Actions, Dependabot, and Codespaces secrets for an organization and/or repositories. + +Usage: + seva secrets [command] + +Available Commands: + create Create Actions, Dependabot, and/or Codespaces secrets from a file. + export Generate a report of Actions, Dependabot, and Codespaces secrets for an organization and/or repositories. + +Flags: + --help Show help for command + +Use "seva secrets [command] --help" for more information about a command. +``` + +#### Create Secrets + +The `gh seva secrets create` command will create secrets from a `csv` file that contains the following information: + +- `SecretLevel`: If the secret was created at the organization or repository level +- `SecretType`: If the secret was created for `Actions`, `Dependabot` or `Codespaces` +- `SecretName`: The name of the secret +- `SecretValue`: The value of the secret that will be [encrypted using the associated `public key`](https://docs.github.com/en/actions/security-guides/encrypted-secrets) +- `SecretAccess`: If an organization level secret, the visibility of the secret (i.e. `all`, `private`, or `scoped`) +- `RepositoryNames`: The name of the repositories that the secret can be accessed from (delimited with `;`) +- `RepositoryIDs`: The `id` of the repositories that the secret can be accessed from (delimited with `;`) + +This extension supports `GitHub.com` and GHES, through the use of `--hostname` and `--token`. + +```sh +$ gh seva secrets create -h +Create Actions, Dependabot, and/or Codespaces secrets for an organization and/or repositories from a file. + +Usage: + seva secrets create [flags] + +Flags: + -d, --debug To debug logging + -f, --from-file string Path and Name of CSV file to create webhooks from (required) + --hostname string GitHub Enterprise Server hostname (default "github.com") + -t, --token string GitHub personal access token for organization to write to (default "gh auth token") + +Global Flags: + --help Show help for command +``` + +#### Export Secrets + +The `gh seva secrets export` command exports secrets for the specified `` or `[repo ..]` list. If `` is selected, **both organization level and repository level secrets will be exported**. The report will contain secrets produces a `csv` report with the following: + +- `SecretLevel`: If the secret was created at the organization or repository level +- `SecretType`: If the secret was created for `Actions`, `Dependabot` or `Codespaces` +- `SecretName`: The name of the secret +- `SecretValue`: This field **will be blank**, we cannot export secret values. +- `SecretAccess`: If an organization level secret, this is the visibility of the secret (i.e. `all`, `private`, or `scoped`) +- `RepositoryNames`: The name of the repositories that the secret can be accessed from (delimited with `;`) +- `RepositoryIDs`: The `id` of the repositories that the secret can be accessed from (delimited with `;`) + +This extension supports `GitHub.com` and GHES, through the use of `--hostname` and `--token`. + +```sh +$ gh seva secrets export -h +Generate a report of Actions, Dependabot, and Codespaces secrets for an organization and/or repositories. + +Usage: + seva secrets export [flags] [repo ...] + +Flags: + -a, --app string List secrets for a specific application or all: {all|actions|codespaces|dependabot} (default "all") + -d, --debug To debug logging + --hostname string GitHub Enterprise Server hostname (default "github.com") + -o, --output-file string Name of file to write CSV report (default "report-20230505162601.csv") + -t, --token string GitHub Personal Access Token (default "gh auth token") + +Global Flags: + --help Show help for command +``` + +### Variables + +Organization level Actions variables can be created and exported, relying on the `csv` file syntax: + +- `VariableLevel`: If the variable was created at the organization or repository level +- `VariableName`: The name of the Actions variable +- `VariableValue`: The value of the Actions variable +- `VariableAccess`: If an organization level variable, this is the visibility of the variable (i.e. `all`, `private`, or `scoped`) +- `RepositoryNames`: The name of the repositories that the variable can be accessed from (delimited with `;`) +- `RepositoryIDs`: The `id` of the repositories that the variable can be accessed from (delimited with `;`) + + +```sh +$ gh seva variables -h +Export and Create Actions variables for an organization and/or repositories. + +Usage: + seva variables [command] + +Available Commands: + create Create Organization Actions variables. + export Generate a report of Actions variables for an organization and/or repositories. + +Flags: + --help Show help for command + +Use "seva variables [command] --help" for more information about a command. +``` + +#### Create Variables + +Organization level variables can be created from a `csv` file using `--from-file` following the format outlined in [`gh seva variables`](#variables). + +* If specifying a Source Organization (`--source-organization`) to retrieve secrets and create under a new Org, the `--source-token` is required. + +```sh +$ gh seva secrets create -h +Create Actions, Dependabot, and/or Codespaces secrets for an organization and/or repositories from a file. + +Usage: + seva secrets create [flags] + +Flags: + -d, --debug To debug logging + -f, --from-file string Path and Name of CSV file to create webhooks from (required) + --hostname string GitHub Enterprise Server hostname (default "github.com") + -t, --token string GitHub personal access token for organization to write to (default "gh auth token") + +Global Flags: + --help Show help for command +``` + +#### Export Variables + +The `gh seva variables export` command exports variables for the specified `` or `[repo ..]` list. If `` is selected, **both organization level and repository level variables will be exported**. The report will contain secrets produces a `csv` report with the following: + +- `VariableLevel`: If the variable was created at the organization or repository level +- `VariableName`: The name of the Actions variable +- `VariableValue`: The value of the Actions variable +- `VariableAccess`: If an organization level variable, this is the visibility of the variable (i.e. `all`, `private`, or `scoped`) +- `RepositoryNames`: The name of the repositories that the variable can be accessed from (delimited with `;`) +- `RepositoryIDs`: The `id` of the repositories that the variable can be accessed from (delimited with `;`) + +This extension supports `GitHub.com` and GHES, through the use of `--hostname` and `--token`. + +```sh +$ gh seva variables export -h +Generate a report of Actions variables for an organization and/or repositories. + +Usage: + seva variables export [flags] [repo ...] + +Flags: + -d, --debug To debug logging + --hostname string GitHub Enterprise Server hostname (default "github.com") + -o, --output-file string Name of file to write CSV report (default "report-20230505163210.csv") + -t, --token string GitHub Personal Access Token (default "gh auth token") + +Global Flags: + --help Show help for command +``` \ No newline at end of file diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..8ad733b --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,26 @@ +package cmd + +import ( + secretsCmd "github.com/katiem0/gh-seva/cmd/secrets" + variablesCmd "github.com/katiem0/gh-seva/cmd/variables" + "github.com/spf13/cobra" +) + +func NewCmdRoot() *cobra.Command { + + cmdRoot := &cobra.Command{ + Use: "seva [flags]", + Short: "Export and Create secrets and variables.", + Long: "Export and Create secrets and variables for an organization and/or repositories.", + } + cmdRoot.PersistentFlags().Bool("help", false, "Show help for command") + + cmdRoot.AddCommand(secretsCmd.NewCmdSecrets()) + cmdRoot.AddCommand(variablesCmd.NewCmdVariables()) + cmdRoot.CompletionOptions.DisableDefaultCmd = true + cmdRoot.SetHelpCommand(&cobra.Command{ + Use: "no-help", + Hidden: true, + }) + return cmdRoot +} diff --git a/cmd/secrets/create/create.go b/cmd/secrets/create/create.go new file mode 100644 index 0000000..b8433d1 --- /dev/null +++ b/cmd/secrets/create/create.go @@ -0,0 +1,317 @@ +package secrets + +import ( + "bytes" + "encoding/csv" + "encoding/json" + "fmt" + "os" + + gh "github.com/cli/go-gh" + "github.com/cli/go-gh/pkg/api" + "github.com/cli/go-gh/pkg/auth" + "github.com/katiem0/gh-seva/internal/data" + "github.com/katiem0/gh-seva/internal/log" + "github.com/spf13/cobra" + "go.uber.org/zap" +) + +type cmdFlags struct { + fileName string + token string + hostname string + debug bool +} + +func NewCmdCreate() *cobra.Command { + //var repository string + cmdFlags := cmdFlags{} + var authToken string + + createCmd := cobra.Command{ + Use: "create [flags]", + Short: "Create Actions, Dependabot, and/or Codespaces secrets from a file.", + Long: "Create Actions, Dependabot, and/or Codespaces secrets for an organization and/or repositories from a file.", + Args: cobra.MinimumNArgs(1), + RunE: func(createCmd *cobra.Command, args []string) error { + var err error + var gqlClient api.GQLClient + var restClient api.RESTClient + + // Reinitialize logging if debugging was enabled + if cmdFlags.debug { + logger, _ := log.NewLogger(cmdFlags.debug) + defer logger.Sync() // nolint:errcheck + zap.ReplaceGlobals(logger) + } + + if cmdFlags.token != "" { + authToken = cmdFlags.token + } else { + t, _ := auth.TokenForHost(cmdFlags.hostname) + authToken = t + } + + gqlClient, err = gh.GQLClient(&api.ClientOptions{ + Headers: map[string]string{ + "Accept": "application/vnd.github.hawkgirl-preview+json", + }, + Host: cmdFlags.hostname, + AuthToken: authToken, + }) + + if err != nil { + zap.S().Errorf("Error arose retrieving graphql client") + return err + } + + restClient, err = gh.RESTClient(&api.ClientOptions{ + Headers: map[string]string{ + "Accept": "application/vnd.github+json", + }, + Host: cmdFlags.hostname, + AuthToken: authToken, + }) + + if err != nil { + zap.S().Errorf("Error arose retrieving rest client") + return err + } + + owner := args[0] + + return runCmdCreate(owner, &cmdFlags, data.NewAPIGetter(gqlClient, restClient)) + }, + } + + // Configure flags for command + createCmd.PersistentFlags().StringVarP(&cmdFlags.token, "token", "t", "", `GitHub personal access token for organization to write to (default "gh auth token")`) + createCmd.PersistentFlags().StringVarP(&cmdFlags.hostname, "hostname", "", "github.com", "GitHub Enterprise Server hostname") + createCmd.Flags().StringVarP(&cmdFlags.fileName, "from-file", "f", "", "Path and Name of CSV file to create webhooks from (required)") + createCmd.PersistentFlags().BoolVarP(&cmdFlags.debug, "debug", "d", false, "To debug logging") + createCmd.MarkFlagRequired("from-file") + + return &createCmd +} + +func runCmdCreate(owner string, cmdFlags *cmdFlags, g *data.APIGetter) error { + var secretData [][]string + var importSecretList []data.ImportedSecret + if len(cmdFlags.fileName) > 0 { + f, err := os.Open(cmdFlags.fileName) + zap.S().Debugf("Opening up file %s", cmdFlags.fileName) + if err != nil { + zap.S().Errorf("Error arose opening secret csv file") + } + // remember to close the file at the end of the program + defer f.Close() + // read csv values using csv.Reader + csvReader := csv.NewReader(f) + secretData, err = csvReader.ReadAll() + zap.S().Debugf("Reading in all lines from csv file") + if err != nil { + zap.S().Errorf("Error arose reading secrets from csv file") + } + importSecretList = g.CreateSecretsList(secretData) + } else { + zap.S().Errorf("Error arose identifying secrets") + } + zap.S().Debugf("Determining secrets to create") + for _, importSecret := range importSecretList { + if importSecret.Level == "Organization" { + zap.S().Debugf("Gathering Organization level secret %s", importSecret.Name) + if importSecret.Type == "Actions" { + zap.S().Debugf("Encrypting Organization level Actions secret %s", importSecret.Name) + publicKey, err := g.GetOrgActionPublicKey(owner) + if err != nil { + zap.S().Errorf("Error arose reading Organization Actions secret from csv file") + } + var responsePublicKey data.PublicKey + err = json.Unmarshal(publicKey, &responsePublicKey) + if err != nil { + return err + } + encryptedSecret, err := g.EncryptSecret(responsePublicKey.Key, importSecret.Value) + if err != nil { + return err + } + zap.S().Debugf("Creating Organization Actions Secret Data for %s", importSecret.Name) + orgSecretObject := data.CreateOrgSecretData(importSecret, responsePublicKey.KeyID, encryptedSecret) + createSecret, err := json.Marshal(orgSecretObject) + + if err != nil { + return err + } + + reader := bytes.NewReader(createSecret) + zap.S().Debugf("Creating Organization Actions Secret %s", importSecret.Name) + + err = g.CreateOrgActionSecret(owner, importSecret.Name, reader) + if err != nil { + zap.S().Errorf("Error arose creating Actions secret %s", importSecret.Name) + } + } else if importSecret.Type == "Codespaces" { + zap.S().Debugf("Encrypting Organization level Codespaces secret %s", importSecret.Name) + publicKey, err := g.GetOrgCodespacesPublicKey(owner) + if err != nil { + zap.S().Errorf("Error arose reading Organization Codespaces secret from csv file") + } + var responsePublicKey data.PublicKey + err = json.Unmarshal(publicKey, &responsePublicKey) + if err != nil { + return err + } + encryptedSecret, err := g.EncryptSecret(responsePublicKey.Key, importSecret.Value) + if err != nil { + return err + } + orgSecretObject := data.CreateOrgSecretData(importSecret, responsePublicKey.KeyID, encryptedSecret) + createSecret, err := json.Marshal(orgSecretObject) + + if err != nil { + return err + } + + reader := bytes.NewReader(createSecret) + zap.S().Debugf("Creating Organization Codespaces Secret %s", importSecret.Name) + + err = g.CreateOrgCodespacesSecret(owner, importSecret.Name, reader) + if err != nil { + zap.S().Errorf("Error arose creating Organization Codespaces secret %s", importSecret.Name) + } + } else if importSecret.Type == "Dependabot" { + zap.S().Debugf("Encrypting Organization level Dependabot secret %s", importSecret.Name) + publicKey, err := g.GetOrgDependabotPublicKey(owner) + if err != nil { + zap.S().Errorf("Error arose reading Organization Dependabot secret from csv file") + } + var responsePublicKey data.PublicKey + err = json.Unmarshal(publicKey, &responsePublicKey) + if err != nil { + return err + } + encryptedSecret, err := g.EncryptSecret(responsePublicKey.Key, importSecret.Value) + if err != nil { + return err + } + orgSecretObject := data.CreateOrgDependabotSecretData(importSecret, responsePublicKey.KeyID, encryptedSecret) + createSecret, err := json.Marshal(orgSecretObject) + + if err != nil { + return err + } + + reader := bytes.NewReader(createSecret) + zap.S().Debugf("Creating Organization Dependabot Secret %s", importSecret.Name) + + err = g.CreateOrgDependabotSecret(owner, importSecret.Name, reader) + if err != nil { + zap.S().Errorf("Error arose creating Organization Dependabot secret %s", importSecret.Name) + } + + } else { + zap.S().Errorf("Error arose reading secret from csv file") + } + } else if importSecret.Level == "Repository" { + repoName := importSecret.RepositoryNames[0] + zap.S().Debugf("Gathering Repository level secret %s", importSecret.Name) + if importSecret.Type == "Actions" { + zap.S().Debugf("Encrypting Repository %s level Actions secret %s", repoName, importSecret.Name) + publicKey, err := g.GetRepoActionPublicKey(owner, repoName) + if err != nil { + zap.S().Errorf("Error arose reading Actions secret from csv file") + } + var responsePublicKey data.PublicKey + err = json.Unmarshal(publicKey, &responsePublicKey) + if err != nil { + return err + } + encryptedSecret, err := g.EncryptSecret(responsePublicKey.Key, importSecret.Value) + if err != nil { + return err + } + zap.S().Debugf("Creating Repository Actions Secret Data for %s", importSecret.Name) + repoSecretObject := data.CreateRepoSecretData(responsePublicKey.KeyID, encryptedSecret) + createSecret, err := json.Marshal(repoSecretObject) + + if err != nil { + return err + } + + reader := bytes.NewReader(createSecret) + zap.S().Debugf("Creating Actions Secret %s", importSecret.Name) + + err = g.CreateRepoActionSecret(owner, repoName, importSecret.Name, reader) + if err != nil { + zap.S().Errorf("Error arose creating Repository Actions secret %s", importSecret.Name) + } + } else if importSecret.Type == "Codespaces" { + zap.S().Debugf("Encrypting Repository level Codespaces secret %s", importSecret.Name) + publicKey, err := g.GetRepoCodespacesPublicKey(owner, repoName) + if err != nil { + zap.S().Errorf("Error arose reading Repository Codespaces secret from csv file") + } + var responsePublicKey data.PublicKey + err = json.Unmarshal(publicKey, &responsePublicKey) + if err != nil { + return err + } + encryptedSecret, err := g.EncryptSecret(responsePublicKey.Key, importSecret.Value) + if err != nil { + return err + } + repoSecretObject := data.CreateRepoSecretData(responsePublicKey.KeyID, encryptedSecret) + createSecret, err := json.Marshal(repoSecretObject) + + if err != nil { + return err + } + + reader := bytes.NewReader(createSecret) + zap.S().Debugf("Creating Repository Codespaces Secret %s", importSecret.Name) + + err = g.CreateRepoCodespacesSecret(owner, repoName, importSecret.Name, reader) + if err != nil { + zap.S().Errorf("Error arose creating Repository Codespaces secret %s", importSecret.Name) + } + } else if importSecret.Type == "Dependabot" { + zap.S().Debugf("Encrypting Repository level Dependabot secret %s", importSecret.Name) + publicKey, err := g.GetRepoDependabotPublicKey(owner, repoName) + if err != nil { + zap.S().Errorf("Error arose reading Repository Dependabot secret from csv file") + } + var responsePublicKey data.PublicKey + err = json.Unmarshal(publicKey, &responsePublicKey) + if err != nil { + return err + } + encryptedSecret, err := g.EncryptSecret(responsePublicKey.Key, importSecret.Value) + if err != nil { + return err + } + repoSecretObject := data.CreateRepoSecretData(responsePublicKey.KeyID, encryptedSecret) + createSecret, err := json.Marshal(repoSecretObject) + + if err != nil { + return err + } + + reader := bytes.NewReader(createSecret) + zap.S().Debugf("Creating Repository Dependabot Secret %s", importSecret.Name) + + err = g.CreateRepoDependabotSecret(owner, repoName, importSecret.Name, reader) + if err != nil { + zap.S().Errorf("Error arose creating Repository Dependabot secret %s", importSecret.Name) + } + + } else { + zap.S().Errorf("Error arose reading secret from csv file") + } + } else { + zap.S().Errorf("Error arose reading in where to create secret %s, check csv file.", importSecret.Name) + } + } + + fmt.Printf("Successfully created secrets for: %s.", owner) + return nil +} diff --git a/cmd/secrets/export/export.go b/cmd/secrets/export/export.go new file mode 100644 index 0000000..23d2e85 --- /dev/null +++ b/cmd/secrets/export/export.go @@ -0,0 +1,513 @@ +package secrets + +import ( + "encoding/csv" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + "time" + + gh "github.com/cli/go-gh" + "github.com/cli/go-gh/pkg/api" + "github.com/cli/go-gh/pkg/auth" + "github.com/katiem0/gh-seva/internal/data" + "github.com/katiem0/gh-seva/internal/log" + "github.com/spf13/cobra" + "go.uber.org/zap" +) + +type cmdFlags struct { + app string + hostname string + token string + reportFile string + debug bool +} + +func NewCmdExport() *cobra.Command { + //var repository string + cmdFlags := cmdFlags{} + var authToken string + + exportCmd := cobra.Command{ + Use: "export [flags] [repo ...] ", + Short: "Generate a report of Actions, Dependabot, and Codespaces secrets for an organization and/or repositories.", + Long: "Generate a report of Actions, Dependabot, and Codespaces secrets for an organization and/or repositories.", + Args: cobra.MinimumNArgs(1), + RunE: func(exportCmd *cobra.Command, args []string) error { + var err error + var gqlClient api.GQLClient + var restClient api.RESTClient + + // Reinitialize logging if debugging was enabled + if cmdFlags.debug { + logger, _ := log.NewLogger(cmdFlags.debug) + defer logger.Sync() // nolint:errcheck + zap.ReplaceGlobals(logger) + } + + if cmdFlags.token != "" { + authToken = cmdFlags.token + } else { + t, _ := auth.TokenForHost(cmdFlags.hostname) + authToken = t + } + + gqlClient, err = gh.GQLClient(&api.ClientOptions{ + Headers: map[string]string{ + "Accept": "application/vnd.github.hawkgirl-preview+json", + }, + Host: cmdFlags.hostname, + AuthToken: authToken, + }) + + if err != nil { + zap.S().Errorf("Error arose retrieving graphql client") + return err + } + + restClient, err = gh.RESTClient(&api.ClientOptions{ + Headers: map[string]string{ + "Accept": "application/vnd.github+json", + }, + Host: cmdFlags.hostname, + AuthToken: authToken, + }) + + if err != nil { + zap.S().Errorf("Error arose retrieving rest client") + return err + } + + owner := args[0] + repos := args[1:] + + if _, err := os.Stat(cmdFlags.reportFile); errors.Is(err, os.ErrExist) { + return err + } + + reportWriter, err := os.OpenFile(cmdFlags.reportFile, os.O_WRONLY|os.O_CREATE, 0644) + + if err != nil { + return err + } + + return runCmdExport(owner, repos, &cmdFlags, data.NewAPIGetter(gqlClient, restClient), reportWriter) + }, + } + + // Determine default report file based on current timestamp; for more info see https://pkg.go.dev/time#pkg-constants + reportFileDefault := fmt.Sprintf("report-%s.csv", time.Now().Format("20060102150405")) + appDefault := "all" + // Configure flags for command + + exportCmd.PersistentFlags().StringVarP(&cmdFlags.app, "app", "a", appDefault, "List secrets for a specific application or all: {all|actions|codespaces|dependabot}") + exportCmd.PersistentFlags().StringVarP(&cmdFlags.token, "token", "t", "", `GitHub Personal Access Token (default "gh auth token")`) + exportCmd.PersistentFlags().StringVarP(&cmdFlags.hostname, "hostname", "", "github.com", "GitHub Enterprise Server hostname") + exportCmd.Flags().StringVarP(&cmdFlags.reportFile, "output-file", "o", reportFileDefault, "Name of file to write CSV report") + exportCmd.PersistentFlags().BoolVarP(&cmdFlags.debug, "debug", "d", false, "To debug logging") + //cmd.MarkPersistentFlagRequired("app") + + return &exportCmd +} + +func runCmdExport(owner string, repos []string, cmdFlags *cmdFlags, g *data.APIGetter, reportWriter io.Writer) error { + var reposCursor *string + var allRepos []data.RepoInfo + + csvWriter := csv.NewWriter(reportWriter) + + err := csvWriter.Write([]string{ + "SecretLevel", + "SecretType", + "SecretName", + "SecretValue", + "SecretAccess", + "RepositoryNames", + "RepositoryIDs", + }) + + if err != nil { + return err + } + + if len(repos) > 0 { + zap.S().Infof("Processing repos: %s", repos) + + for _, repo := range repos { + + zap.S().Debugf("Processing %s/%s", owner, repo) + + repoQuery, err := g.GetRepo(owner, repo) + if err != nil { + return err + } + allRepos = append(allRepos, repoQuery.Repository) + } + + } else { + // Prepare writer for outputting report + for { + zap.S().Debugf("Processing list of repositories for %s", owner) + reposQuery, err := g.GetReposList(owner, reposCursor) + + if err != nil { + return err + } + + allRepos = append(allRepos, reposQuery.Organization.Repositories.Nodes...) + + reposCursor = &reposQuery.Organization.Repositories.PageInfo.EndCursor + + if !reposQuery.Organization.Repositories.PageInfo.HasNextPage { + break + } + } + } + + // Writing to CSV Org level Actions secrets + if len(repos) == 0 && (cmdFlags.app == "all" || cmdFlags.app == "actions") { + zap.S().Debugf("Gathering Actions Secrets for %s", owner) + orgSecrets, err := g.GetOrgActionSecrets(owner) + if err != nil { + return err + } + var oActionResponseObject data.SecretsResponse + err = json.Unmarshal(orgSecrets, &oActionResponseObject) + if err != nil { + return err + } + + for _, orgSecret := range oActionResponseObject.Secrets { + if orgSecret.Visibility == "selected" { + zap.S().Debugf("Gathering Actions Secrets for %s that are scoped to specific repositories", owner) + scoped_repo, err := g.GetScopedOrgActionSecrets(owner, orgSecret.Name) + if err != nil { + zap.S().Error("Error raised in writing output", zap.Error(err)) + } + var responseOObject data.ScopedSecretsResponse + err = json.Unmarshal(scoped_repo, &responseOObject) + if err != nil { + return err + } + var concatRepos []string + var concatRepoIds []string + for _, scopeSecret := range responseOObject.Repositories { + concatRepos = append(concatRepos, scopeSecret.Name) + stringRepoId := strconv.Itoa(scopeSecret.ID) + concatRepoIds = append(concatRepoIds, stringRepoId) + } + err = csvWriter.Write([]string{ + "Organization", + "Actions", + orgSecret.Name, + "", + orgSecret.Visibility, + strings.Join(concatRepos, ";"), + strings.Join(concatRepoIds, ";"), + }) + if err != nil { + zap.S().Error("Error raised in writing output", zap.Error(err)) + } + } else if orgSecret.Visibility == "private" { + zap.S().Debugf("Gathering Actions Secret %s for %s that is accessible to all internal and private repositories.", orgSecret.Name, owner) + var concatRepos []string + var concatRepoIds []string + for _, repoActPrivateSecret := range allRepos { + if repoActPrivateSecret.Visibility != "public" { + concatRepos = append(concatRepos, repoActPrivateSecret.Name) + stringRepoId := strconv.Itoa(repoActPrivateSecret.DatabaseId) + concatRepoIds = append(concatRepoIds, stringRepoId) + } + } + err = csvWriter.Write([]string{ + "Organization", + "Actions", + orgSecret.Name, + "", + orgSecret.Visibility, + strings.Join(concatRepos, ";"), + strings.Join(concatRepoIds, ";"), + }) + if err != nil { + zap.S().Error("Error raised in writing output", zap.Error(err)) + } + } else { + zap.S().Debugf("Gathering public Actions Secret %s for %s", orgSecret.Name, owner) + err = csvWriter.Write([]string{ + "Organization", + "Actions", + orgSecret.Name, + "", + orgSecret.Visibility, + "", + "", + }) + if err != nil { + zap.S().Error("Error raised in writing output", zap.Error(err)) + } + } + } + } + + // Writing to CSV Org level Dependabot secrets + if len(repos) == 0 && (cmdFlags.app == "all" || cmdFlags.app == "dependabot") { + + orgDepSecrets, err := g.GetOrgDependabotSecrets(owner) + if err != nil { + return err + } + var oDepResponseObject data.SecretsResponse + err = json.Unmarshal(orgDepSecrets, &oDepResponseObject) + if err != nil { + return err + } + + for _, orgDepSecret := range oDepResponseObject.Secrets { + if orgDepSecret.Visibility == "selected" { + zap.S().Debugf("Gathering Dependabot Secret %s for %s that is scoped to specific repositories", orgDepSecret.Name, owner) + scoped_repo, err := g.GetScopedOrgDependabotSecrets(owner, orgDepSecret.Name) + if err != nil { + return err + } + var rDepResponseObject data.ScopedSecretsResponse + err = json.Unmarshal(scoped_repo, &rDepResponseObject) + if err != nil { + return err + } + var concatRepos []string + var concatRepoIds []string + for _, depScopeSecret := range rDepResponseObject.Repositories { + concatRepos = append(concatRepos, depScopeSecret.Name) + stringRepoId := strconv.Itoa(depScopeSecret.ID) + concatRepoIds = append(concatRepoIds, stringRepoId) + } + err = csvWriter.Write([]string{ + "Organization", + "Dependabot", + orgDepSecret.Name, + "", + orgDepSecret.Visibility, + strings.Join(concatRepos, ";"), + strings.Join(concatRepoIds, ";"), + }) + if err != nil { + zap.S().Error("Error raised in writing output", zap.Error(err)) + } + } else if orgDepSecret.Visibility == "private" { + zap.S().Debugf("Gathering Dependabot Secret %s for %s that is accessible to all internal and private repositories.", orgDepSecret.Name, owner) + var concatRepos []string + var concatRepoIds []string + for _, repoPrivateSecret := range allRepos { + if repoPrivateSecret.Visibility != "public" { + concatRepos = append(concatRepos, repoPrivateSecret.Name) + stringRepoId := strconv.Itoa(repoPrivateSecret.DatabaseId) + concatRepoIds = append(concatRepoIds, stringRepoId) + } + } + err = csvWriter.Write([]string{ + "Organization", + "Dependabot", + orgDepSecret.Name, + "", + orgDepSecret.Visibility, + strings.Join(concatRepos, ";"), + strings.Join(concatRepoIds, ";"), + }) + if err != nil { + zap.S().Error("Error raised in writing output", zap.Error(err)) + } + } else { + zap.S().Debugf("Gathering public Dependabot Secret %s for %s", orgDepSecret.Name, owner) + err = csvWriter.Write([]string{ + "Organization", + "Dependabot", + orgDepSecret.Name, + "", + orgDepSecret.Visibility, + "", + "", + }) + if err != nil { + zap.S().Error("Error raised in writing output", zap.Error(err)) + } + } + } + } + + // Writing to CSV Org level Codespaces secrets + if len(repos) == 0 && (cmdFlags.app == "all" || cmdFlags.app == "codespaces") { + + orgCodeSecrets, err := g.GetOrgCodespacesSecrets(owner) + if err != nil { + return err + } + var oCodeResponseObject data.SecretsResponse + err = json.Unmarshal(orgCodeSecrets, &oCodeResponseObject) + if err != nil { + return err + } + + for _, orgCodeSecret := range oCodeResponseObject.Secrets { + zap.S().Debugf("Gathering Codespaces Secrets for %s that are scoped to specific repositories", owner) + if orgCodeSecret.Visibility == "selected" { + scoped_repo, err := g.GetScopedOrgCodespacesSecrets(owner, orgCodeSecret.Name) + if err != nil { + return err + } + var rCodeResponseObject data.ScopedSecretsResponse + err = json.Unmarshal(scoped_repo, &rCodeResponseObject) + if err != nil { + return err + } + var concatRepos []string + var concatRepoIds []string + for _, codeScopeSecret := range rCodeResponseObject.Repositories { + concatRepos = append(concatRepos, codeScopeSecret.Name) + stringRepoId := strconv.Itoa(codeScopeSecret.ID) + concatRepoIds = append(concatRepoIds, stringRepoId) + } + err = csvWriter.Write([]string{ + "Organization", + "Codespaces", + orgCodeSecret.Name, + "", + orgCodeSecret.Visibility, + strings.Join(concatRepos, ";"), + strings.Join(concatRepoIds, ";"), + }) + if err != nil { + zap.S().Error("Error raised in writing output", zap.Error(err)) + } + } else if orgCodeSecret.Visibility == "private" { + zap.S().Debugf("Gathering Codespaces Secret %s for %s that is accessible to all internal and private repositories.", orgCodeSecret.Name, owner) + var concatRepos []string + var concatRepoIds []string + for _, repoCodePrivateSecret := range allRepos { + if repoCodePrivateSecret.Visibility != "public" { + concatRepos = append(concatRepos, repoCodePrivateSecret.Name) + stringRepoId := strconv.Itoa(repoCodePrivateSecret.DatabaseId) + concatRepoIds = append(concatRepoIds, stringRepoId) + } + } + err = csvWriter.Write([]string{ + "Organization", + "Codespaces", + orgCodeSecret.Name, + "", + orgCodeSecret.Visibility, + strings.Join(concatRepos, ";"), + strings.Join(concatRepoIds, ";"), + }) + if err != nil { + zap.S().Error("Error raised in writing output", zap.Error(err)) + } + } else { + zap.S().Debugf("Gathering public Codespaces Secret %s for %s", orgCodeSecret.Name, owner) + err = csvWriter.Write([]string{ + "Organization", + "Codespaces", + orgCodeSecret.Name, + "", + orgCodeSecret.Visibility, + "", + "", + }) + if err != nil { + zap.S().Error("Error raised in writing output", zap.Error(err)) + } + } + } + } + + // Writing to CSV repository level Secrets + for _, singleRepo := range allRepos { + // Writing to CSV repository level Actions secrets + if cmdFlags.app == "all" || cmdFlags.app == "actions" { + repoActionSecretsList, err := g.GetRepoActionSecrets(owner, singleRepo.Name) + if err != nil { + return err + } + var repoActionResponseObject data.SecretsResponse + err = json.Unmarshal(repoActionSecretsList, &repoActionResponseObject) + if err != nil { + return err + } + for _, repoActionsSecret := range repoActionResponseObject.Secrets { + err = csvWriter.Write([]string{ + "Repository", + "Actions", + repoActionsSecret.Name, + "", + "RepoOnly", + singleRepo.Name, + strconv.Itoa(singleRepo.DatabaseId), + }) + if err != nil { + zap.S().Error("Error raised in writing output", zap.Error(err)) + } + } + } + // Writing to CSV repository level Dependabot secrets + if cmdFlags.app == "all" || cmdFlags.app == "dependabot" { + repoDepSecretsList, err := g.GetRepoDependabotSecrets(owner, singleRepo.Name) + if err != nil { + return err + } + var repoDepResponseObject data.SecretsResponse + err = json.Unmarshal(repoDepSecretsList, &repoDepResponseObject) + if err != nil { + return err + } + for _, repoDepSecret := range repoDepResponseObject.Secrets { + err = csvWriter.Write([]string{ + "Repository", + "Dependabot", + repoDepSecret.Name, + "", + "RepoOnly", + singleRepo.Name, + strconv.Itoa(singleRepo.DatabaseId), + }) + if err != nil { + zap.S().Error("Error raised in writing output", zap.Error(err)) + } + } + } + // Writing to CSV repository level Codespaces secrets + if cmdFlags.app == "all" || cmdFlags.app == "codespaces" { + repoCodeSecretsList, err := g.GetRepoCodespacesSecrets(owner, singleRepo.Name) + if err != nil { + zap.S().Error("Error raised in writing output", zap.Error(err)) + } + var repoCodeResponseObject data.SecretsResponse + err = json.Unmarshal(repoCodeSecretsList, &repoCodeResponseObject) + if err != nil { + return err + } + for _, repoCodeSecret := range repoCodeResponseObject.Secrets { + err = csvWriter.Write([]string{ + "Repository", + "Codespaces", + repoCodeSecret.Name, + "", + "RepoOnly", + singleRepo.Name, + strconv.Itoa(singleRepo.DatabaseId), + }) + if err != nil { + zap.S().Error("Error raised in writing output", zap.Error(err)) + } + } + } + } + + csvWriter.Flush() + fmt.Printf("Successfully exported secrets for %s", owner) + return nil + +} diff --git a/cmd/secrets/secrets.go b/cmd/secrets/secrets.go new file mode 100644 index 0000000..3e1ade9 --- /dev/null +++ b/cmd/secrets/secrets.go @@ -0,0 +1,22 @@ +package secrets + +import ( + createCmd "github.com/katiem0/gh-seva/cmd/secrets/create" + exportCmd "github.com/katiem0/gh-seva/cmd/secrets/export" + "github.com/spf13/cobra" +) + +func NewCmdSecrets() *cobra.Command { + + cmd := &cobra.Command{ + Use: "secrets [flags]", + Args: cobra.MinimumNArgs(1), + Short: "Export and Create secrets for an organization and/or repositories.", + Long: "Export and Create Actions, Dependabot, and Codespaces secrets for an organization and/or repositories.", + } + cmd.Flags().Bool("help", false, "Show help for command") + cmd.AddCommand(exportCmd.NewCmdExport()) + cmd.AddCommand(createCmd.NewCmdCreate()) + + return cmd +} diff --git a/cmd/variables/create/createvars.go b/cmd/variables/create/createvars.go new file mode 100644 index 0000000..2e24c86 --- /dev/null +++ b/cmd/variables/create/createvars.go @@ -0,0 +1,261 @@ +package createvars + +import ( + "bytes" + "encoding/csv" + "encoding/json" + "errors" + "fmt" + "os" + + "github.com/cli/go-gh" + "github.com/cli/go-gh/pkg/api" + "github.com/cli/go-gh/pkg/auth" + "github.com/katiem0/gh-seva/internal/data" + "github.com/katiem0/gh-seva/internal/log" + "github.com/spf13/cobra" + "go.uber.org/zap" +) + +type cmdFlags struct { + sourceToken string + sourceOrg string + sourceHostname string + fileName string + token string + hostname string + debug bool +} + +func NewCmdCreate() *cobra.Command { + //var repository string + cmdFlags := cmdFlags{} + var authToken string + + createCmd := cobra.Command{ + Use: "create [flags]", + Short: "Create Organization Actions variables.", + Long: "Create Organization Actions variables for a specified organization or organization and repositories level variables from a file.", + Args: cobra.MinimumNArgs(1), + PreRunE: func(createCmd *cobra.Command, args []string) error { + if len(cmdFlags.fileName) == 0 && len(cmdFlags.sourceOrg) == 0 { + return errors.New("A file or source organization must be specified where variables will be created from.") + } else if len(cmdFlags.sourceOrg) > 0 && len(cmdFlags.sourceToken) == 0 { + return errors.New("A Personal Access Token must be specified to access variables from the Source Organization.") + } else if len(cmdFlags.fileName) > 0 && len(cmdFlags.sourceOrg) > 0 { + return errors.New("Specify only one of `--source-organization` or `from-file`.") + } + return nil + }, + RunE: func(createCmd *cobra.Command, args []string) error { + var err error + var gqlClient api.GQLClient + var restClient api.RESTClient + + // Reinitialize logging if debugging was enabled + if cmdFlags.debug { + logger, _ := log.NewLogger(cmdFlags.debug) + defer logger.Sync() // nolint:errcheck + zap.ReplaceGlobals(logger) + } + + if cmdFlags.token != "" { + authToken = cmdFlags.token + } else { + t, _ := auth.TokenForHost(cmdFlags.hostname) + authToken = t + } + + gqlClient, err = gh.GQLClient(&api.ClientOptions{ + Headers: map[string]string{ + "Accept": "application/vnd.github.hawkgirl-preview+json", + }, + Host: cmdFlags.hostname, + AuthToken: authToken, + }) + + if err != nil { + zap.S().Errorf("Error arose retrieving graphql client") + return err + } + + restClient, err = gh.RESTClient(&api.ClientOptions{ + Headers: map[string]string{ + "Accept": "application/vnd.github+json", + }, + Host: cmdFlags.hostname, + AuthToken: authToken, + }) + + if err != nil { + zap.S().Errorf("Error arose retrieving rest client") + return err + } + + owner := args[0] + + return runCmdCreate(owner, &cmdFlags, data.NewAPIGetter(gqlClient, restClient)) + }, + } + + // Configure flags for command + createCmd.PersistentFlags().StringVarP(&cmdFlags.token, "token", "t", "", `GitHub personal access token for organization to write to (default "gh auth token")`) + createCmd.PersistentFlags().StringVarP(&cmdFlags.sourceToken, "source-token", "s", "", `GitHub personal access token for Source Organization (Required for --source-organization)`) + createCmd.PersistentFlags().StringVarP(&cmdFlags.sourceOrg, "source-organization", "o", "", `Name of the Source Organization to copy variables from (Requires --source-token)`) + createCmd.PersistentFlags().StringVarP(&cmdFlags.hostname, "hostname", "", "github.com", "GitHub Enterprise Server hostname") + createCmd.PersistentFlags().StringVarP(&cmdFlags.sourceHostname, "source-hostname", "", "github.com", "GitHub Enterprise Server hostname where variables are copied from") + createCmd.Flags().StringVarP(&cmdFlags.fileName, "from-file", "f", "", "Path and Name of CSV file to create variables from") + createCmd.PersistentFlags().BoolVarP(&cmdFlags.debug, "debug", "d", false, "To debug logging") + + return &createCmd +} + +func runCmdCreate(owner string, cmdFlags *cmdFlags, g *data.APIGetter) error { + var variableData [][]string + var variablesList []data.ImportedVariable + + if len(cmdFlags.fileName) > 0 { + f, err := os.Open(cmdFlags.fileName) + zap.S().Debugf("Opening up file %s", cmdFlags.fileName) + if err != nil { + zap.S().Errorf("Error arose opening webhooks csv file") + } + // remember to close the file at the end of the program + defer f.Close() + + // read csv values using csv.Reader + csvReader := csv.NewReader(f) + variableData, err = csvReader.ReadAll() + zap.S().Debugf("Reading in all lines from csv file") + if err != nil { + zap.S().Errorf("Error arose reading variables from csv file") + } + variablesList = g.CreateVariableList(variableData) + zap.S().Debugf("Identifying Variable list to create under %s", owner) + zap.S().Debugf("Determining variables to create") + for _, variable := range variablesList { + + if variable.Level == "Organization" { + zap.S().Debugf("Gathering Organization level variable %s", variable.Name) + importOrgVar := data.CreateOrgVariableData(variable) + createVariable, err := json.Marshal(importOrgVar) + + if err != nil { + return err + } + + reader := bytes.NewReader(createVariable) + zap.S().Debugf("Creating Variables under %s", owner) + err = g.CreateOrganizationVariable(owner, reader) + if err != nil { + zap.S().Errorf("Error arose creating variable with %s", variable.Name) + } + } else if variable.Level == "Repository" { + repoName := variable.SelectedRepos[0] + zap.S().Debugf("Gathering Repository level variable %s", variable.Name) + importRepoVar := data.CreateRepoVariableData(variable) + createVariable, err := json.Marshal(importRepoVar) + + if err != nil { + return err + } + + reader := bytes.NewReader(createVariable) + zap.S().Debugf("Creating Variables under %s", repoName) + err = g.CreateRepoVariable(owner, repoName, reader) + if err != nil { + zap.S().Errorf("Error arose creating variable with %s", variable.Name) + } + } + } + } else if len(cmdFlags.sourceOrg) > 0 { + zap.S().Debugf("Reading in variables from %s", cmdFlags.sourceOrg) + var authToken string + var restSourceClient api.RESTClient + + if cmdFlags.sourceToken != "" { + authToken = cmdFlags.sourceToken + } else { + t, _ := auth.TokenForHost(cmdFlags.sourceHostname) + authToken = t + } + + restSourceClient, err := gh.RESTClient(&api.ClientOptions{ + Headers: map[string]string{ + "Accept": "application/vnd.github+json", + }, + Host: cmdFlags.sourceHostname, + AuthToken: authToken, + }) + if err != nil { + zap.S().Errorf("Error arose retrieving source rest client") + return err + } + + zap.S().Debugf("Gathering variables %s", cmdFlags.sourceOrg) + + variableResponse, err := data.GetSourceOrganizationVariables(cmdFlags.sourceOrg, data.NewSourceAPIGetter(restSourceClient)) + if err != nil { + return err + } + var response data.VariableResponse + err = json.Unmarshal(variableResponse, &response) + if err != nil { + return err + } + for _, variable := range response.Variables { + if variable.Visibility == "selected" { + zap.S().Debugf("Creating Scoped Variables under %s", owner) + var orgVariable data.CreateOrgVariable + scoped_repo, err := data.GetScopedSourceOrgActionVariables(cmdFlags.sourceOrg, variable.Name, data.NewSourceAPIGetter(restSourceClient)) + if err != nil { + zap.S().Error("Error raised in writing output", zap.Error(err)) + } + + var responseOObject data.ScopedVariableResponse + err = json.Unmarshal(scoped_repo, &responseOObject) + if err != nil { + return err + } + var concatRepoIds []int + for _, scopedVar := range responseOObject.Repositories { + concatRepoIds = append(concatRepoIds, scopedVar.ID) + } + orgVariable.SelectedReposIDs = concatRepoIds + orgVariable.Name = variable.Name + orgVariable.Value = variable.Value + orgVariable.Visibility = variable.Visibility + + createOrgVariable, err := json.Marshal(orgVariable) + + if err != nil { + return err + } + reader := bytes.NewReader(createOrgVariable) + zap.S().Debugf("Creating Variables under %s", owner) + err = g.CreateOrganizationVariable(owner, reader) + if err != nil { + zap.S().Errorf("Error arose creating variable with %s", variable.Name) + } + } else { + orgVariable := data.CreateOrgSourceVariableData(variable) + createOrgVariable, err := json.Marshal(orgVariable) + + if err != nil { + return err + } + reader := bytes.NewReader(createOrgVariable) + zap.S().Debugf("Creating Variable %s under %s", variable.Name, owner) + err = g.CreateOrganizationVariable(owner, reader) + if err != nil { + zap.S().Errorf("Error arose creating variable with %s", variable.Name) + } + } + } + } else { + zap.S().Errorf("Error arose identifying variables") + } + + fmt.Printf("Successfully created variables for: %s.", owner) + return nil +} diff --git a/cmd/variables/export/exportvars.go b/cmd/variables/export/exportvars.go new file mode 100644 index 0000000..cfae955 --- /dev/null +++ b/cmd/variables/export/exportvars.go @@ -0,0 +1,276 @@ +package exportvars + +import ( + "encoding/csv" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + "time" + + "github.com/cli/go-gh" + "github.com/cli/go-gh/pkg/api" + "github.com/cli/go-gh/pkg/auth" + "github.com/katiem0/gh-seva/internal/data" + "github.com/katiem0/gh-seva/internal/log" + "github.com/spf13/cobra" + "go.uber.org/zap" +) + +type cmdFlags struct { + hostname string + token string + reportFile string + debug bool +} + +func NewCmdExport() *cobra.Command { + //var repository string + cmdFlags := cmdFlags{} + var authToken string + + exportCmd := cobra.Command{ + Use: "export [flags] [repo ...] ", + Short: "Generate a report of Actions variables for an organization and/or repositories.", + Long: "Generate a report of Actions variables for an organization and/or repositories.", + Args: cobra.MinimumNArgs(1), + RunE: func(exportCmd *cobra.Command, args []string) error { + var err error + var gqlClient api.GQLClient + var restClient api.RESTClient + + // Reinitialize logging if debugging was enabled + if cmdFlags.debug { + logger, _ := log.NewLogger(cmdFlags.debug) + defer logger.Sync() // nolint:errcheck + zap.ReplaceGlobals(logger) + } + + if cmdFlags.token != "" { + authToken = cmdFlags.token + } else { + t, _ := auth.TokenForHost(cmdFlags.hostname) + authToken = t + } + + gqlClient, err = gh.GQLClient(&api.ClientOptions{ + Headers: map[string]string{ + "Accept": "application/vnd.github.hawkgirl-preview+json", + }, + Host: cmdFlags.hostname, + AuthToken: authToken, + }) + + if err != nil { + zap.S().Errorf("Error arose retrieving graphql client") + return err + } + + restClient, err = gh.RESTClient(&api.ClientOptions{ + Headers: map[string]string{ + "Accept": "application/vnd.github+json", + }, + Host: cmdFlags.hostname, + AuthToken: authToken, + }) + + if err != nil { + zap.S().Errorf("Error arose retrieving rest client") + return err + } + + owner := args[0] + repos := args[1:] + + if _, err := os.Stat(cmdFlags.reportFile); errors.Is(err, os.ErrExist) { + return err + } + + reportWriter, err := os.OpenFile(cmdFlags.reportFile, os.O_WRONLY|os.O_CREATE, 0644) + + if err != nil { + return err + } + + return runCmdExport(owner, repos, &cmdFlags, data.NewAPIGetter(gqlClient, restClient), reportWriter) + }, + } + + // Determine default report file based on current timestamp; for more info see https://pkg.go.dev/time#pkg-constants + reportFileDefault := fmt.Sprintf("report-%s.csv", time.Now().Format("20060102150405")) + // Configure flags for command + exportCmd.PersistentFlags().StringVarP(&cmdFlags.token, "token", "t", "", `GitHub Personal Access Token (default "gh auth token")`) + exportCmd.PersistentFlags().StringVarP(&cmdFlags.hostname, "hostname", "", "github.com", "GitHub Enterprise Server hostname") + exportCmd.Flags().StringVarP(&cmdFlags.reportFile, "output-file", "o", reportFileDefault, "Name of file to write CSV report") + exportCmd.PersistentFlags().BoolVarP(&cmdFlags.debug, "debug", "d", false, "To debug logging") + //cmd.MarkPersistentFlagRequired("app") + + return &exportCmd +} + +func runCmdExport(owner string, repos []string, cmdFlags *cmdFlags, g *data.APIGetter, reportWriter io.Writer) error { + var reposCursor *string + var allRepos []data.RepoInfo + + csvWriter := csv.NewWriter(reportWriter) + + err := csvWriter.Write([]string{ + "VariableLevel", + "VariableName", + "VariableValue", + "VariableAccess", + "RepositoryNames", + "RepositoryIDs", + }) + if err != nil { + return err + } + if len(repos) > 0 { + zap.S().Infof("Processing repos: %s", repos) + + for _, repo := range repos { + + zap.S().Debugf("Processing %s/%s", owner, repo) + + repoQuery, err := g.GetRepo(owner, repo) + if err != nil { + return err + } + allRepos = append(allRepos, repoQuery.Repository) + } + + } else { + // Prepare writer for outputting report + for { + zap.S().Debugf("Processing list of repositories for %s", owner) + reposQuery, err := g.GetReposList(owner, reposCursor) + + if err != nil { + return err + } + + allRepos = append(allRepos, reposQuery.Organization.Repositories.Nodes...) + + reposCursor = &reposQuery.Organization.Repositories.PageInfo.EndCursor + + if !reposQuery.Organization.Repositories.PageInfo.HasNextPage { + break + } + } + } + // Writing to CSV Org level Actions Variables + if len(repos) == 0 { + zap.S().Debugf("Gathering ORganization level Actions Variables for %s", owner) + orgVariables, err := g.GetOrgActionVariables(owner) + if err != nil { + return err + } + var oActionResponseObject data.VariableResponse + err = json.Unmarshal(orgVariables, &oActionResponseObject) + if err != nil { + return err + } + + for _, orgVariable := range oActionResponseObject.Variables { + if orgVariable.Visibility == "selected" { + zap.S().Debugf("Gathering Actions Variables for %s that are scoped to specific repositories", owner) + scoped_repo, err := g.GetScopedOrgActionVariables(owner, orgVariable.Name) + if err != nil { + zap.S().Error("Error raised in writing output", zap.Error(err)) + } + var responseOObject data.ScopedVariableResponse + err = json.Unmarshal(scoped_repo, &responseOObject) + if err != nil { + return err + } + var concatRepos []string + var concatRepoIds []string + for _, scopeVariable := range responseOObject.Repositories { + concatRepos = append(concatRepos, scopeVariable.Name) + stringRepoId := strconv.Itoa(scopeVariable.ID) + concatRepoIds = append(concatRepoIds, stringRepoId) + } + err = csvWriter.Write([]string{ + "Organization", + orgVariable.Name, + orgVariable.Value, + orgVariable.Visibility, + strings.Join(concatRepos, ";"), + strings.Join(concatRepoIds, ";"), + }) + if err != nil { + zap.S().Error("Error raised in writing output", zap.Error(err)) + } + } else if orgVariable.Visibility == "private" { + zap.S().Debugf("Gathering Actions Variables %s for %s that is accessible to all internal and private repositories.", orgVariable.Name, owner) + var concatRepos []string + var concatRepoIds []string + for _, repoActPrivateVars := range allRepos { + if repoActPrivateVars.Visibility != "public" { + concatRepos = append(concatRepos, repoActPrivateVars.Name) + stringRepoId := strconv.Itoa(repoActPrivateVars.DatabaseId) + concatRepoIds = append(concatRepoIds, stringRepoId) + } + } + err = csvWriter.Write([]string{ + "Organization", + orgVariable.Name, + orgVariable.Value, + orgVariable.Visibility, + strings.Join(concatRepos, ";"), + strings.Join(concatRepoIds, ";"), + }) + if err != nil { + zap.S().Error("Error raised in writing output", zap.Error(err)) + } + } else { + zap.S().Debugf("Gathering public Actions Secret %s for %s", orgVariable.Name, owner) + err = csvWriter.Write([]string{ + "Organization", + orgVariable.Name, + orgVariable.Value, + orgVariable.Visibility, + "", + "", + }) + if err != nil { + zap.S().Error("Error raised in writing output", zap.Error(err)) + } + } + } + } + + // Writing to CSV repository level Variables + for _, singleRepo := range allRepos { + // Writing to CSV repository level Actions Variables + repoActionVariablesList, err := g.GetRepoActionVariables(owner, singleRepo.Name) + if err != nil { + return err + } + var repoActionResponseObject data.VariableResponse + err = json.Unmarshal(repoActionVariablesList, &repoActionResponseObject) + if err != nil { + return err + } + for _, repoActionsVars := range repoActionResponseObject.Variables { + err = csvWriter.Write([]string{ + "Repository", + repoActionsVars.Name, + repoActionsVars.Value, + "RepoOnly", + singleRepo.Name, + strconv.Itoa(singleRepo.DatabaseId), + }) + if err != nil { + zap.S().Error("Error raised in writing output", zap.Error(err)) + } + } + } + + csvWriter.Flush() + fmt.Printf("Successfully exported variables for %s", owner) + return nil +} diff --git a/cmd/variables/variables.go b/cmd/variables/variables.go new file mode 100644 index 0000000..d6cc5cc --- /dev/null +++ b/cmd/variables/variables.go @@ -0,0 +1,22 @@ +package variables + +import ( + createCmd "github.com/katiem0/gh-seva/cmd/variables/create" + exportCmd "github.com/katiem0/gh-seva/cmd/variables/export" + "github.com/spf13/cobra" +) + +func NewCmdVariables() *cobra.Command { + + cmd := &cobra.Command{ + Use: "variables ", + Short: "Export and Create variables for an organization and/or repositories.", + Long: "Export and Create Actions variables for an organization and/or repositories.", + } + cmd.Flags().Bool("help", false, "Show help for command") + + cmd.AddCommand(exportCmd.NewCmdExport()) + cmd.AddCommand(createCmd.NewCmdCreate()) + + return cmd +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1d5c46f --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module github.com/katiem0/gh-seva + +go 1.20 + +require github.com/cli/go-gh v1.2.1 + +require ( + github.com/cli/safeexec v1.0.0 // indirect + github.com/cli/shurcooL-graphql v0.0.2 // indirect + github.com/henvic/httpretty v0.0.6 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/muesli/termenv v0.12.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29 // indirect + github.com/spf13/cobra v1.7.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect + go.uber.org/atomic v1.7.0 // indirect + go.uber.org/multierr v1.6.0 // indirect + go.uber.org/zap v1.24.0 // indirect + golang.org/x/crypto v0.8.0 // indirect + golang.org/x/net v0.9.0 // indirect + golang.org/x/sys v0.7.0 // indirect + golang.org/x/term v0.7.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5338c11 --- /dev/null +++ b/go.sum @@ -0,0 +1,81 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/cli/go-gh v1.2.1 h1:xFrjejSsgPiwXFP6VYynKWwxLQcNJy3Twbu82ZDlR/o= +github.com/cli/go-gh v1.2.1/go.mod h1:Jxk8X+TCO4Ui/GarwY9tByWm/8zp4jJktzVZNlTW5VM= +github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI= +github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= +github.com/cli/shurcooL-graphql v0.0.2 h1:rwP5/qQQ2fM0TzkUTwtt6E2LbIYf6R+39cUXTa04NYk= +github.com/cli/shurcooL-graphql v0.0.2/go.mod h1:tlrLmw/n5Q/+4qSvosT+9/W5zc8ZMjnJeYBxSdb4nWA= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/henvic/httpretty v0.0.6 h1:JdzGzKZBajBfnvlMALXXMVQWxWMF/ofTy8C3/OSUTxs= +github.com/henvic/httpretty v0.0.6/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc= +github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29 h1:B1PEwpArrNp4dkQrfxh/abbBAOZBVp0ds+fBEOUOqOc= +github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8= +github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= +go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ= +golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= +golang.org/x/net v0.0.0-20220923203811-8be639271d50/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0 h1:BEvjmm5fURWqcfbSKTdpkDXYBrUS1c0m8agp14W48vQ= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/data/actions.go b/internal/data/actions.go new file mode 100644 index 0000000..ee67604 --- /dev/null +++ b/internal/data/actions.go @@ -0,0 +1,103 @@ +package data + +import ( + "fmt" + "io" + "log" + + "go.uber.org/zap" +) + +func (g *APIGetter) GetOrgActionSecrets(owner string) ([]byte, error) { + url := fmt.Sprintf("orgs/%s/actions/secrets", owner) + + resp, err := g.restClient.Request("GET", url, nil) + if err != nil { + log.Fatal(err) + } + responseData, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + return responseData, err +} + +func (g *APIGetter) GetRepoActionSecrets(owner string, repo string) ([]byte, error) { + url := fmt.Sprintf("repos/%s/%s/actions/secrets", owner, repo) + + resp, err := g.restClient.Request("GET", url, nil) + if err != nil { + log.Fatal(err) + } + responseData, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + return responseData, err +} + +func (g *APIGetter) GetScopedOrgActionSecrets(owner string, secret string) ([]byte, error) { + url := fmt.Sprintf("orgs/%s/actions/secrets/%s/repositories", owner, secret) + + resp, err := g.restClient.Request("GET", url, nil) + if err != nil { + log.Fatal(err) + } + responseData, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + return responseData, err +} + +func (g *APIGetter) GetOrgActionPublicKey(owner string) ([]byte, error) { + url := fmt.Sprintf("orgs/%s/actions/secrets/public-key", owner) + zap.S().Debugf("Getting public-key for %v", url) + resp, err := g.restClient.Request("GET", url, nil) + if err != nil { + log.Printf("Body read error, %v", err) + } + defer resp.Body.Close() + responseData, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Body read error, %v", err) + } + return responseData, err +} + +func (g *APIGetter) GetRepoActionPublicKey(owner string, repo string) ([]byte, error) { + url := fmt.Sprintf("repos/%s/%s/actions/secrets/public-key", owner, repo) + zap.S().Debugf("Getting public-key for %v", url) + resp, err := g.restClient.Request("GET", url, nil) + if err != nil { + log.Printf("Body read error, %v", err) + } + defer resp.Body.Close() + responseData, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Body read error, %v", err) + } + return responseData, err +} + +func (g *APIGetter) CreateOrgActionSecret(owner string, secret string, data io.Reader) error { + url := fmt.Sprintf("orgs/%s/actions/secrets/%s", owner, secret) + + resp, err := g.restClient.Request("PUT", url, data) + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() + return err +} + +func (g *APIGetter) CreateRepoActionSecret(owner string, repo string, secret string, data io.Reader) error { + url := fmt.Sprintf("repos/%s/%s/actions/secrets/%s", owner, repo, secret) + + resp, err := g.restClient.Request("PUT", url, data) + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() + return err +} diff --git a/internal/data/codespaces.go b/internal/data/codespaces.go new file mode 100644 index 0000000..17b8aad --- /dev/null +++ b/internal/data/codespaces.go @@ -0,0 +1,103 @@ +package data + +import ( + "fmt" + "io" + "log" + + "go.uber.org/zap" +) + +func (g *APIGetter) GetOrgCodespacesSecrets(owner string) ([]byte, error) { + url := fmt.Sprintf("orgs/%s/codespaces/secrets", owner) + + resp, err := g.restClient.Request("GET", url, nil) + if err != nil { + log.Fatal(err) + } + responseData, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + return responseData, err +} + +func (g *APIGetter) GetRepoCodespacesSecrets(owner string, repo string) ([]byte, error) { + url := fmt.Sprintf("repos/%s/%s/codespaces/secrets", owner, repo) + + resp, err := g.restClient.Request("GET", url, nil) + if err != nil { + log.Fatal(err) + } + responseData, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + return responseData, err +} + +func (g *APIGetter) GetScopedOrgCodespacesSecrets(owner string, secret string) ([]byte, error) { + url := fmt.Sprintf("orgs/%s/codespaces/secrets/%s/repositories", owner, secret) + + resp, err := g.restClient.Request("GET", url, nil) + if err != nil { + log.Fatal(err) + } + responseData, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + return responseData, err +} + +func (g *APIGetter) GetOrgCodespacesPublicKey(owner string) ([]byte, error) { + url := fmt.Sprintf("orgs/%s/codespaces/secrets/public-key", owner) + zap.S().Debugf("Getting public-key for %v", url) + resp, err := g.restClient.Request("GET", url, nil) + if err != nil { + log.Printf("Body read error, %v", err) + } + defer resp.Body.Close() + responseData, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Body read error, %v", err) + } + return responseData, err +} + +func (g *APIGetter) GetRepoCodespacesPublicKey(owner string, repo string) ([]byte, error) { + url := fmt.Sprintf("repos/%s/%s/codespaces/secrets/public-key", owner, repo) + zap.S().Debugf("Getting public-key for %v", url) + resp, err := g.restClient.Request("GET", url, nil) + if err != nil { + log.Printf("Body read error, %v", err) + } + defer resp.Body.Close() + responseData, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Body read error, %v", err) + } + return responseData, err +} + +func (g *APIGetter) CreateOrgCodespacesSecret(owner string, secret string, data io.Reader) error { + url := fmt.Sprintf("orgs/%s/codespaces/secrets/%s", owner, secret) + + resp, err := g.restClient.Request("PUT", url, data) + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() + return err +} + +func (g *APIGetter) CreateRepoCodespacesSecret(owner string, repo string, secret string, data io.Reader) error { + url := fmt.Sprintf("repos/%s/%s/codespaces/secrets/%s", owner, repo, secret) + + resp, err := g.restClient.Request("PUT", url, data) + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() + return err +} diff --git a/internal/data/dependabot.go b/internal/data/dependabot.go new file mode 100644 index 0000000..b6551dc --- /dev/null +++ b/internal/data/dependabot.go @@ -0,0 +1,103 @@ +package data + +import ( + "fmt" + "io" + "log" + + "go.uber.org/zap" +) + +func (g *APIGetter) GetOrgDependabotSecrets(owner string) ([]byte, error) { + url := fmt.Sprintf("orgs/%s/dependabot/secrets", owner) + + resp, err := g.restClient.Request("GET", url, nil) + if err != nil { + log.Fatal(err) + } + responseData, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + return responseData, err +} + +func (g *APIGetter) GetRepoDependabotSecrets(owner string, repo string) ([]byte, error) { + url := fmt.Sprintf("repos/%s/%s/dependabot/secrets", owner, repo) + + resp, err := g.restClient.Request("GET", url, nil) + if err != nil { + log.Fatal(err) + } + responseData, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + return responseData, err +} + +func (g *APIGetter) GetScopedOrgDependabotSecrets(owner string, secret string) ([]byte, error) { + url := fmt.Sprintf("orgs/%s/dependabot/secrets/%s/repositories", owner, secret) + + resp, err := g.restClient.Request("GET", url, nil) + if err != nil { + log.Fatal(err) + } + responseData, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + return responseData, err +} + +func (g *APIGetter) GetOrgDependabotPublicKey(owner string) ([]byte, error) { + url := fmt.Sprintf("orgs/%s/dependabot/secrets/public-key", owner) + zap.S().Debugf("Getting public-key for %v", url) + resp, err := g.restClient.Request("GET", url, nil) + if err != nil { + log.Printf("Body read error, %v", err) + } + defer resp.Body.Close() + responseData, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Body read error, %v", err) + } + return responseData, err +} + +func (g *APIGetter) GetRepoDependabotPublicKey(owner string, repo string) ([]byte, error) { + url := fmt.Sprintf("repos/%s/%s/dependabot/secrets/public-key", owner, repo) + zap.S().Debugf("Getting public-key for %v", url) + resp, err := g.restClient.Request("GET", url, nil) + if err != nil { + log.Printf("Body read error, %v", err) + } + defer resp.Body.Close() + responseData, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Body read error, %v", err) + } + return responseData, err +} + +func (g *APIGetter) CreateOrgDependabotSecret(owner string, secret string, data io.Reader) error { + url := fmt.Sprintf("orgs/%s/dependabot/secrets/%s", owner, secret) + + resp, err := g.restClient.Request("PUT", url, data) + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() + return err +} + +func (g *APIGetter) CreateRepoDependabotSecret(owner string, repo string, secret string, data io.Reader) error { + url := fmt.Sprintf("repos/%s/%s/dependabot/secrets/%s", owner, repo, secret) + + resp, err := g.restClient.Request("PUT", url, data) + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() + return err +} diff --git a/internal/data/general.go b/internal/data/general.go new file mode 100644 index 0000000..0511154 --- /dev/null +++ b/internal/data/general.go @@ -0,0 +1,160 @@ +package data + +import ( + "io" + "time" + + "github.com/cli/go-gh/pkg/api" + "github.com/shurcooL/graphql" +) + +type Getter interface { + GetReposList(owner string, endCursor *string) ([]ReposQuery, error) + GetRepo(owner string, name string) ([]RepoQuery, error) + GetOrgActionSecrets(owner string) ([]byte, error) + GetRepoActionSecrets(owner string, repo string) ([]byte, error) + GetScopedOrgActionSecrets(owner string, secret string) ([]byte, error) + GetOrgDependabotSecrets(owner string) ([]byte, error) + GetRepoDependabotSecrets(owner string, repo string) ([]byte, error) + GetScopedOrgDependabotSecrets(owner string, secret string) ([]byte, error) + GetOrgCodespacesSecrets(owner string) ([]byte, error) + GetRepoCodespacesSecrets(owner string, repo string) ([]byte, error) + GetScopedOrgCodespacesSecrets(owner string, secret string) ([]byte, error) + CreateSecretsList(data [][]string) []ImportedSecret + GetOrgActionPublicKey(owner string) ([]byte, error) + GetRepoActionPublicKey(owner string, repo string) ([]byte, error) + GetOrgCodespacesPublicKey(owner string) ([]byte, error) + GetRepoCodespacesPublicKey(owner string, repo string) ([]byte, error) + GetOrgDependabotPublicKey(owner string) ([]byte, error) + GetRepoDependabotPublicKey(owner string, repo string) ([]byte, error) + EncryptSecret(publickey string, secret string) (string, error) + CreateOrgActionSecret(owner string, secret string, data io.Reader) error + CreateRepoActionSecret(owner string, repo string, secret string, data io.Reader) error + CreateOrgCodespacesSecret(owner string, secret string, data io.Reader) error + CreateRepoCodespacesSecret(owner string, repo string, secret string, data io.Reader) error + CreateOrgDependabotSecret(owner string, secret string, data io.Reader) error + CreateRepoDependabotSecret(owner string, repo string, secret string, data io.Reader) error + GetOrgActionVariables(owner string) ([]byte, error) + GetRepoActionVariables(owner string, repo string) ([]byte, error) + GetScopedOrgActionVariables(owner string, secret string) ([]byte, error) +} + +type APIGetter struct { + gqlClient api.GQLClient + restClient api.RESTClient +} + +func NewAPIGetter(gqlClient api.GQLClient, restClient api.RESTClient) *APIGetter { + return &APIGetter{ + gqlClient: gqlClient, + restClient: restClient, + } +} + +type sourceAPIGetter struct { + restClient api.RESTClient +} + +func NewSourceAPIGetter(restClient api.RESTClient) *sourceAPIGetter { + return &sourceAPIGetter{ + restClient: restClient, + } +} + +type SecretExport struct { + SecretLevel string + SecretType string + SecretName string + SecretAccess string + RepositoryName string + RepositoryID int +} + +type RepoInfo struct { + DatabaseId int `json:"databaseId"` + Name string `json:"name"` + UpdatedAt time.Time `json:"updatedAt"` + Visibility string `json:"visibility"` +} + +type ReposQuery struct { + Organization struct { + Repositories struct { + TotalCount int + Nodes []RepoInfo + PageInfo struct { + EndCursor string + HasNextPage bool + } + } `graphql:"repositories(first: 100, after: $endCursor)"` + } `graphql:"organization(login: $owner)"` +} + +type RepoQuery struct { + Repository RepoInfo `graphql:"repository(owner: $owner, name: $name)"` +} + +func (g *APIGetter) GetReposList(owner string, endCursor *string) (*ReposQuery, error) { + query := new(ReposQuery) + variables := map[string]interface{}{ + "endCursor": (*graphql.String)(endCursor), + "owner": graphql.String(owner), + } + + err := g.gqlClient.Query("getRepos", &query, variables) + + return query, err +} + +func (g *APIGetter) GetRepo(owner string, name string) (*RepoQuery, error) { + query := new(RepoQuery) + variables := map[string]interface{}{ + "owner": graphql.String(owner), + "name": graphql.String(name), + } + + err := g.gqlClient.Query("getRepo", &query, variables) + return query, err +} + +type SecretsResponse struct { + TotalCount int `json:"total_count"` + Secrets []Secret `json:"secrets"` +} + +type Secret struct { + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Visibility string `json:"visibility"` + SelectedRepos string `json:"selected_repositories_url"` +} + +type ScopedSecretsResponse struct { + TotalCount int `json:"total_count"` + Repositories []ScopedRepository `json:"repositories"` +} + +type ScopedRepository struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type VariableResponse struct { + TotalCount int `json:"total_count"` + Variables []Variable `json:"variables"` +} + +type Variable struct { + Name string `json:"name"` + Value string `json:"value"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Visibility string `json:"visibility"` + SelectedRepos string `json:"selected_repositories_url"` +} + +type ScopedVariableResponse struct { + TotalCount int `json:"total_count"` + Repositories []ScopedRepository `json:"repositories"` +} diff --git a/internal/data/importsecrets.go b/internal/data/importsecrets.go new file mode 100644 index 0000000..a58ebda --- /dev/null +++ b/internal/data/importsecrets.go @@ -0,0 +1,118 @@ +package data + +import ( + "crypto/rand" + "encoding/base64" + "strconv" + "strings" + + "golang.org/x/crypto/nacl/box" +) + +type ImportedSecret struct { + Level string `json:"level"` + Type string `json:"type"` + Name string `json:"name"` + Value string `json:"value"` + Access string `json:"visibility"` + RepositoryNames []string `json:"selected_repositories"` + RepositoryIDs []string `json:"selected_repository_ids"` +} + +type PublicKey struct { + KeyID string `json:"key_id"` + Key string `json:"key"` +} + +type CreateOrgSecret struct { + EncryptedValue string `json:"encrypted_value"` + KeyID string `json:"key_id"` + Visibility string `json:"visibility"` + SelectedRepos []int `json:"selected_repository_ids"` +} + +// Address Dependabot API differences +type CreateOrgDepSecret struct { + EncryptedValue string `json:"encrypted_value"` + KeyID string `json:"key_id"` + Visibility string `json:"visibility"` + SelectedRepos []string `json:"selected_repository_ids"` +} + +type CreateRepoSecret struct { + EncryptedValue string `json:"encrypted_value"` + KeyID string `json:"key_id"` +} + +func (g *APIGetter) CreateSecretsList(data [][]string) []ImportedSecret { + // convert csv lines to array of structs + var importSecretList []ImportedSecret + var secret ImportedSecret + for _, each := range data[1:] { + secret.Level = each[0] + secret.Type = each[1] + secret.Name = each[2] + secret.Value = each[3] + secret.Access = each[4] + secret.RepositoryNames = strings.Split(each[5], ";") + secret.RepositoryIDs = strings.Split(each[6], ";") + importSecretList = append(importSecretList, secret) + } + return importSecretList +} + +func (g *APIGetter) EncryptSecret(publickey string, secret string) (string, error) { + var pkBytes [32]byte + copy(pkBytes[:], publickey) + secretBytes := secret + + out := make([]byte, 0, + len(secretBytes)+ + box.Overhead+ + len(pkBytes)) + + enc, err := box.SealAnonymous( + out, []byte(secretBytes), &pkBytes, rand.Reader, + ) + if err != nil { + return "", err + } + + encEnc := base64.StdEncoding.EncodeToString(enc) + + return encEnc, nil +} + +func CreateOrgSecretData(secret ImportedSecret, keyID string, encryptedValue string) *CreateOrgSecret { + secretArray := make([]int, len(secret.RepositoryIDs)) + for i := range secretArray { + secretArray[i], _ = strconv.Atoi(secret.RepositoryIDs[i]) + } + s := CreateOrgSecret{ + EncryptedValue: encryptedValue, + KeyID: keyID, + Visibility: secret.Access, + SelectedRepos: secretArray, + } + return &s +} + +// Separate function to address that the Dependabot Org Secret API +// is an array of strings instead of an array of integers +func CreateOrgDependabotSecretData(secret ImportedSecret, keyID string, encryptedValue string) *CreateOrgDepSecret { + s := CreateOrgDepSecret{ + EncryptedValue: encryptedValue, + KeyID: keyID, + Visibility: secret.Access, + SelectedRepos: secret.RepositoryIDs, + } + return &s +} + +func CreateRepoSecretData(keyID string, encryptedValue string) *CreateRepoSecret { + s := CreateRepoSecret{ + EncryptedValue: encryptedValue, + KeyID: keyID, + } + return &s +} diff --git a/internal/data/importvariable.go b/internal/data/importvariable.go new file mode 100644 index 0000000..2593911 --- /dev/null +++ b/internal/data/importvariable.go @@ -0,0 +1,138 @@ +package data + +import ( + "fmt" + "io" + "log" + "strconv" + "strings" + + "go.uber.org/zap" +) + +type ImportedVariable struct { + Level string + Name string `json:"name"` + Value string `json:"value"` + Visibility string `json:"visibility"` + SelectedRepos []string + SelectedReposIDs []string `json:"selected_repository_ids"` +} + +type CreateOrgVariable struct { + Name string `json:"name"` + Value string `json:"value"` + Visibility string `json:"visibility"` + SelectedReposIDs []int `json:"selected_repository_ids"` +} + +type CreateVariableAll struct { + Name string `json:"name"` + Value string `json:"value"` + Visibility string `json:"visibility"` +} + +type CreateRepoVariable struct { + Name string `json:"name"` + Value string `json:"value"` +} + +func (g *APIGetter) CreateVariableList(data [][]string) []ImportedVariable { + // convert csv lines to array of structs + var variableList []ImportedVariable + var vars ImportedVariable + for _, each := range data[1:] { + vars.Level = each[0] + vars.Name = each[1] + vars.Value = each[2] + vars.Visibility = each[3] + vars.SelectedRepos = strings.Split(each[4], ";") + vars.SelectedReposIDs = strings.Split(each[5], ";") + + variableList = append(variableList, vars) + } + return variableList +} + +func GetSourceOrganizationVariables(owner string, g *sourceAPIGetter) ([]byte, error) { + url := fmt.Sprintf("orgs/%s/actions/variables", owner) + zap.S().Debugf("Reading in variables from %v", url) + resp, err := g.restClient.Request("GET", url, nil) + if err != nil { + log.Printf("Body read error, %v", err) + } + defer resp.Body.Close() + responseData, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Body read error, %v", err) + } + return responseData, err +} + +func GetScopedSourceOrgActionVariables(owner string, secret string, g *sourceAPIGetter) ([]byte, error) { + url := fmt.Sprintf("orgs/%s/actions/variables/%s/repositories", owner, secret) + + resp, err := g.restClient.Request("GET", url, nil) + if err != nil { + log.Fatal(err) + } + responseData, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + return responseData, err +} + +func (g *APIGetter) CreateOrganizationVariable(owner string, data io.Reader) error { + url := fmt.Sprintf("orgs/%s/actions/variables", owner) + + resp, err := g.restClient.Request("POST", url, data) + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() + return err +} + +func (g *APIGetter) CreateRepoVariable(owner string, repo string, data io.Reader) error { + url := fmt.Sprintf("repos/%s/%s/actions/variables", owner, repo) + + resp, err := g.restClient.Request("POST", url, data) + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() + return err +} + +func CreateOrgVariableData(variable ImportedVariable) *CreateOrgVariable { + variableArray := make([]int, len(variable.SelectedReposIDs)) + for i := range variableArray { + variableArray[i], _ = strconv.Atoi(variable.SelectedReposIDs[i]) + } + fmt.Println(variableArray) + s := CreateOrgVariable{ + Name: variable.Name, + Value: variable.Value, + Visibility: variable.Visibility, + SelectedReposIDs: variableArray, + } + return &s +} + +func CreateRepoVariableData(variable ImportedVariable) *CreateRepoVariable { + s := CreateRepoVariable{ + Name: variable.Name, + Value: variable.Value, + } + return &s +} + +func CreateOrgSourceVariableData(variable Variable) *CreateVariableAll { + s := CreateVariableAll{ + Name: variable.Name, + Value: variable.Value, + Visibility: variable.Visibility, + } + return &s +} diff --git a/internal/data/variables.go b/internal/data/variables.go new file mode 100644 index 0000000..8a20571 --- /dev/null +++ b/internal/data/variables.go @@ -0,0 +1,49 @@ +package data + +import ( + "fmt" + "io" + "log" +) + +func (g *APIGetter) GetOrgActionVariables(owner string) ([]byte, error) { + url := fmt.Sprintf("orgs/%s/actions/variables", owner) + + resp, err := g.restClient.Request("GET", url, nil) + if err != nil { + log.Fatal(err) + } + responseData, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + return responseData, err +} + +func (g *APIGetter) GetRepoActionVariables(owner string, repo string) ([]byte, error) { + url := fmt.Sprintf("repos/%s/%s/actions/variables", owner, repo) + + resp, err := g.restClient.Request("GET", url, nil) + if err != nil { + log.Fatal(err) + } + responseData, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + return responseData, err +} + +func (g *APIGetter) GetScopedOrgActionVariables(owner string, secret string) ([]byte, error) { + url := fmt.Sprintf("orgs/%s/actions/variables/%s/repositories", owner, secret) + + resp, err := g.restClient.Request("GET", url, nil) + if err != nil { + log.Fatal(err) + } + responseData, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + return responseData, err +} diff --git a/internal/log/log.go b/internal/log/log.go new file mode 100644 index 0000000..d8f5b6b --- /dev/null +++ b/internal/log/log.go @@ -0,0 +1,24 @@ +package log + +import ( + "go.uber.org/zap" +) + +func NewLogger(debug bool) (*zap.Logger, error) { + + level := zap.InfoLevel + + if debug { + level = zap.DebugLevel + } + + loggerConfig := zap.Config{ + Level: zap.NewAtomicLevelAt(level), + Encoding: "console", + EncoderConfig: zap.NewDevelopmentEncoderConfig(), + OutputPaths: []string{"stderr"}, + ErrorOutputPaths: []string{"stderr"}, + } + + return loggerConfig.Build() +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..d4b9441 --- /dev/null +++ b/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "os" + + "github.com/katiem0/gh-seva/cmd" +) + +func main() { + // Instantiate and execute root command + cmd := cmd.NewCmdRoot() + if err := cmd.Execute(); err != nil { + os.Exit(1) + } +}