Skip to content

Commit

Permalink
Initial implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
doodlesbykumbi committed Mar 24, 2020
1 parent db6b8ef commit 903048e
Show file tree
Hide file tree
Showing 13 changed files with 715 additions and 0 deletions.
20 changes: 20 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Release with goreleaser
on:
push:
tags:
- v* # Push events to matching v*, i.e. v1.0, v20.15.10
jobs:
build:
runs-on: ubuntu-latest
name: goreleaser
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v1
with:
go-version: 1.13
- name: Release via goreleaser
uses: goreleaser/goreleaser-action@master
with:
args: release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
15 changes: 15 additions & 0 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
name: Unit Tests
on: [push]

jobs:
test:
name: Run Unit Tests
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v1
with:
go-version: 1.13
- name: Run Unit Tests
run: go test -v -count=1 ./...
18 changes: 18 additions & 0 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
before:
hooks:
- go mod download
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
archives:
- name_template: '{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}'
files:
- none*
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ .Tag }}-next"
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
The MIT License (MIT)

Copyright © 2020 Kumbirai Tanekha

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
107 changes: 107 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# github-secrets-writer

**github-secrets-writer** is a command-line tool that simplifies the process of creating or updating [Github secrets](https://help.github.com/en/actions/configuring-and-managing-workflows/creating-and-storing-encrypted-secrets) by carrying out the encryption for you, and writing the secrets to Github via the API.
> Secrets are encrypted environment variables created in a repository and can only be used by GitHub Actions
**Table of Contents**
* [What's all the fuss?](#whats-all-the-fuss)
* [Installation](#installation)
+ [Binaries](#binaries)
+ [Via Go](#via-go)
* [Usage](#usage)
+ [Write secrets](#write-secrets)

## What's all the fuss?

GitHub secrets are encrypted using public-key authenticated encryption and the Poly1305 cipher algorithm. The [Github developer documentation](https://developer.github.com/v3/actions/secrets/#create-or-update-a-secret-for-a-repository) suggests carrying out the encryption using [LibSodium](https://libsodium.gitbook.io/doc/bindings_for_other_languages).

😞 Without **github-secrets-writer**, a user would have to piece together bits of code to:

1. Encrypt a secret using LibSodium (requires installing dependencies)
2. Write the secret to Github using the API
3. Repeat (1) and (2) for multiple secrets

🚀 **github-secrets-writer** offers the user some convenience by doing all the above for you, without the need to install additional dependencies. It comes as a binary and it uses Go's supplementary [cryptography libraries](https://go.googlesource.com/crypto) (that are interoperable with LibSodium) to carry out the encryption, then writes the secrets to Github using the [Go library for accessing the GitHub API](https://github.com/google/go-github).

## Installation
### Binaries
For installation instructions from binaries please visit the [Releases Page](https://github.com/doodlesbykumbi/github-secrets-writer/releases).

As an example, if you wanted to install v0.1.0 on OSX you would run the following commands in your terminal:
```
# Download the release archive with the binary
wget https://github.com/doodlesbykumbi/github-secrets-writer/releases/download/v0.1.0/github-secrets-writer_darwin_amd64.tar.gz
# Uncompress the release archive to /usr/local/bin. You might need to run this with `sudo`
tar -C /usr/local/bin -zxvf github-secrets-writer_darwin_amd64.tar.gz
```

### Via Go
```console
$ go get -u github.com/doodlesbykumbi/github-secrets-writer
```

## Usage

**github-secrets-writer** uses flags to specify the Github repository and the secrets to write to it.

NOTE: An OAuth token **must** be provided via the `GITHUB_TOKEN` environment variable, this is used to authenticate to the Github API. Access tokens require [`repo` scope](https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/#available-scopes) for private repos and [`public_repo` scope](https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/#available-scopes) for public repos. GitHub Apps must have the `secrets` permission to use the API. Authenticated users must have collaborator access to a repository to create, update, or read secrets.

```console
$ github-secrets-writer -h
Create or update multiple Github secrets sourced from literal values or files.

Key/value pairs representing a secret name and the source of the secret value are provided via the flags --from-file and --from-literal. Depending on the key/value pairs specified a single invocation may carry out zero or more writes to the Github secrets of the repository.

NOTE: An OAuth token **must** be provided via the 'GITHUB_TOKEN' environment variable, this is used to authenticate to the Github API. Access tokens require 'repo' scope for private repos and 'public_repo' scope for public repos. GitHub Apps must have the 'secrets' permission to use the API. Authenticated users must have collaborator access to a repository to create, update, or read secrets.

Usage:
github-secrets-writer --owner=owner --repo=repo [--from-literal=secretName1=secretValue1] [--from-file=secretName2=/path/to/secretValue2]

Examples:
# Write a single secret from a literal value
github-secrets-writer --owner=owner --repo=repo --from-literal=secretName1=secretValue1

# Write a single secret from a file
github-secrets-writer --owner=owner --repo=repo --from-file=secretName1=secretFilePath

# Write multiple secrets, one from a literal value and one from a file
github-secrets-writer --owner=owner --repo=repo --from-literal=secretName1=secretValue1 --from-file=secretName2=/path/to/secretValue2

Flags:
--from-file stringArray specify secret name and literal value pairs e.g. secretname=somevalue (zero or more)
--from-literal stringArray specify secret name and source file pairs e.g. secretname=somefile (zero or more)
-h, --help help for github-secrets-writer
--owner string owner of the repository e.g. an organisation or user (required)
--repo string name of the repository (required)
```

### Write secrets

To write secrets to a repository you must invoke **github-secrets-writer** with the relevant flags. Below is an example of writing 3 secrets (2 from literal values and 1 from a source file) to the `example-owner/example-repo` repository.

```console
$ GITHUB_TOKEN=... github-secrets-writer \
--owner example-owner \
--repo example-repo \
--from-literal secretName1=secretValue1 \
--from-literal secretName2=secretValue2 \
--from-file secretName3=/path/to/secretValue3
Write results:

secretName1: 204 No Content
secretName2: 204 No Content
secretName3: 204 No Content
```

Following the successful writes, you can [use the encrypted secrets in a workflow](https://help.github.com/en/actions/configuring-and-managing-workflows/creating-and-storing-encrypted-secrets#using-encrypted-secrets-in-a-workflow) as shown below.

```yaml
steps:
- name: Hello world action
with: # Set the secret as an input
secretName1: ${{ secrets.secretName1 }}
env: # Or as an environment variable
secretName2: ${{ secrets.secretName2 }}
secretName3: ${{ secrets.secretName3 }}
```
110 changes: 110 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package cmd

import (
"fmt"
"os"

"github.com/spf13/cobra"

"github.com/spf13/viper"

gsw "github.com/doodlesbykumbi/github-secrets-writer/pkg"
)

var owner string
var repo string
var fromFile []string
var fromLiteral []string
var secrets []Secret

func fatal(err error) {
_, _ = os.Stderr.Write([]byte(fmt.Sprintf("ERROR: %s\n", err)))
os.Exit(1)
}

var rootCmd = &cobra.Command{
Use: "github-secrets-writer --owner=owner --repo=repo [--from-literal=secretName1=secretValue1] [--from-file=secretName2=/path/to/secretValue2]",
DisableFlagsInUseLine: true,
Short: "Create or update multiple Github secrets sourced from literal values or files.",
Long: `Create or update multiple Github secrets sourced from literal values or files.
Key/value pairs representing a secret name and the source of the secret value are provided via the flags --from-file and --from-literal. Depending on the key/value pairs specified a single invocation may carry out zero or more writes to the Github secrets of the repository.
NOTE: An OAuth token **must** be provided via the 'GITHUB_TOKEN' environment variable, this is used to authenticate to the Github API. Access tokens require 'repo' scope for private repos and 'public_repo' scope for public repos. GitHub Apps must have the 'secrets' permission to use the API. Authenticated users must have collaborator access to a repository to create, update, or read secrets.
`,
Example: `# Write a single secret from a literal value
github-secrets-writer --owner=owner --repo=repo --from-literal=secretName1=secretValue1
# Write a single secret from a file
github-secrets-writer --owner=owner --repo=repo --from-file=secretName1=/path/to/secretValue1
# Write multiple secrets, one from a literal value and one from a file
github-secrets-writer --owner=owner --repo=repo --from-literal=secretName1=secretValue1 --from-file=secretName2=/path/to/secretValue2`,
Run: func(cmd *cobra.Command, args []string) {
var err error
defer func() {
if err != nil {
fatal(err)
}
}()
secretWriter := gsw.NewSecretWriter(viper.GetString("token"))
var hasFailures bool

fmt.Printf("Write results:\n\n")
for _, secret := range secrets {
result, wErr := secretWriter.Write(owner, repo, secret.Name, secret.Value)
if wErr != nil {
hasFailures = true
result = wErr.Error()
}

fmt.Printf("%s: %s\n", secret.Name, result)
}

if hasFailures {
err = fmt.Errorf("encountered some failures, see above")
return
}
},
PreRunE: func(cmd *cobra.Command, args []string) error {
if !viper.IsSet("token") {
return fmt.Errorf("envvar not set: GITHUB_TOKEN")
}

var err error
secrets, err = CollectSecrets(fromLiteral, fromFile)
if err != nil {
return err
}

if len(secrets) == 0 {
return fmt.Errorf("no secret name-source pairs provided, you must specify at least one of --from-literal or --from-file")
}

return nil
},
}

func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}

func init() {
cobra.OnInitialize(initConfig)

rootCmd.Flags().StringVar(&owner, "owner", "", "owner of the repository e.g. an organisation or user (required)")
rootCmd.Flags().StringVar(&repo, "repo", "", "name of the repository (required)")
rootCmd.Flags().StringArrayVar(&fromFile, "from-file", []string{}, "specify secret name and literal value pairs e.g. secretname=somevalue (zero or more)")
rootCmd.Flags().StringArrayVar(&fromLiteral, "from-literal", []string{}, "specify secret name and source file pairs e.g. secretname=somefile (zero or more)")

_ = rootCmd.MarkFlagRequired("owner")
_ = rootCmd.MarkFlagRequired("repo")
}

// initConfig reads in ENV variables.
func initConfig() {
_ = viper.BindEnv("token", "GITHUB_TOKEN")
}
92 changes: 92 additions & 0 deletions cmd/secret.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package cmd

import (
"fmt"
"io/ioutil"
"strings"

"github.com/pkg/errors"
)

type Secret struct {
Name string
Value []byte
}

func CollectSecrets(literalSources, fileSources []string) ([]Secret, error) {
var allSecrets []Secret

var err error

secrets, err := secretsFromLiteralSources(literalSources)
if err != nil {
return nil, errors.Wrap(err, fmt.Sprintf(
"literal sources %v", literalSources))
}
allSecrets = append(allSecrets, secrets...)

secrets, err = secretsFromFileSources(fileSources)
if err != nil {
return nil, errors.Wrap(err, fmt.Sprintf(
"file sources: %v", fileSources))
}
allSecrets = append(allSecrets, secrets...)

var secretNames = map[string]bool{}
for _, secret := range allSecrets {
if secretNames[secret.Name] {
return nil, fmt.Errorf(
"multiple sources provided for secret name: %s",
secret.Name,
)
}

secretNames[secret.Name] = true
}

return allSecrets, nil
}

func secretsFromLiteralSources(sources []string) ([]Secret, error) {
var secrets []Secret
for _, s := range sources {
secretName, secretValue, err := parseSecretSource(s)
if err != nil {
return nil, err
}
secrets = append(secrets, Secret{Name: secretName, Value: []byte(secretValue)})
}
return secrets, nil
}

func secretsFromFileSources(sources []string) ([]Secret, error) {
var secrets []Secret
for _, s := range sources {
secretName, fPath, err := parseSecretSource(s)
if err != nil {
return nil, err
}
content, err := ioutil.ReadFile(fPath)
if err != nil {
return nil, err
}
secrets = append(secrets, Secret{Name: secretName, Value: content})
}
return secrets, nil
}

// parseSecretSource parses the source key=val pair into its component pieces.
// This functionality is distinguished from strings.SplitN(source, "=", 2) since
// it returns an error in the case of empty keys, values, or a missing equals sign.
func parseSecretSource(source string) (secretName, value string, err error) {
// leading equal is invalid
if strings.Index(source, "=") == 0 {
return "", "", fmt.Errorf("invalid secret source %v, expected key=value", source)
}
// split after the first equal (so values can have the = character)
items := strings.SplitN(source, "=", 2)
if len(items) != 2 {
return "", "", fmt.Errorf("invalid secret source %v, expected key=value", source)
}
return items[0], strings.Trim(items[1], "\"'"), nil
}
Loading

0 comments on commit 903048e

Please sign in to comment.