├── .gitattributes ├── .rspec ├── spec ├── approvals │ └── cli │ │ ├── bulk │ │ ├── init │ │ ├── clean │ │ ├── list │ │ ├── save-only │ │ ├── clean-dry │ │ ├── show │ │ ├── show-visible │ │ ├── save │ │ ├── usage │ │ ├── save-clean │ │ ├── save-dry │ │ ├── init-file │ │ └── help │ │ ├── org │ │ ├── delete │ │ │ └── ok │ │ ├── save │ │ │ └── ok │ │ ├── list │ │ │ └── ok │ │ ├── usage │ │ └── help │ │ ├── repo │ │ ├── save │ │ │ └── ok │ │ ├── delete │ │ │ └── ok │ │ ├── list │ │ │ └── ok │ │ ├── usage │ │ └── help │ │ ├── exception │ │ └── commands ├── fake_public_key.rb ├── spec_mixin.rb ├── fixtures │ └── secrets.yml ├── secret_hub │ ├── bin_spec.rb │ ├── refinements │ │ └── string_obfuscation_spec.rb │ ├── sodium_spec.rb │ ├── config_spec.rb │ ├── commands │ │ ├── org_spec.rb │ │ ├── repo_spec.rb │ │ └── bulk_spec.rb │ └── git_hub_client_spec.rb ├── spec_helper.rb └── mock_api │ └── server.rb ├── lib ├── secret_hub │ ├── version.rb │ ├── commands │ │ ├── base.rb │ │ ├── org.rb │ │ ├── repo.rb │ │ └── bulk.rb │ ├── exceptions.rb │ ├── sodium.rb │ ├── config-template.yml │ ├── refinements │ │ └── string_obfuscation.rb │ ├── cli.rb │ ├── config.rb │ └── github_client.rb └── secret_hub.rb ├── .codespellrc ├── .gitignore ├── docker-compose.yml ├── .rubocop.yml ├── Dockerfile ├── Gemfile ├── bin └── secrethub ├── Runfile ├── .github └── workflows │ ├── test.yml │ └── docker-latest.yml ├── LICENSE ├── secret_hub.gemspec ├── schemas └── secrethub.json ├── CHANGELOG.md └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | Runfile linguist-language=Ruby 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format documentation 3 | --fail-fast -------------------------------------------------------------------------------- /spec/approvals/cli/bulk/init: -------------------------------------------------------------------------------- 1 | Saved tmp/secrets.yml 2 | -------------------------------------------------------------------------------- /spec/approvals/cli/org/delete/ok: -------------------------------------------------------------------------------- 1 | Deleted matz PASSWORD 2 | -------------------------------------------------------------------------------- /spec/approvals/cli/org/save/ok: -------------------------------------------------------------------------------- 1 | Saved matz PASSWORD 2 | -------------------------------------------------------------------------------- /spec/approvals/cli/repo/save/ok: -------------------------------------------------------------------------------- 1 | Saved matz/ruby PASSWORD 2 | -------------------------------------------------------------------------------- /spec/approvals/cli/org/list/ok: -------------------------------------------------------------------------------- 1 | matz: 2 | - PASSWORD 3 | - SECRET 4 | -------------------------------------------------------------------------------- /spec/approvals/cli/repo/delete/ok: -------------------------------------------------------------------------------- 1 | Deleted matz/ruby PASSWORD 2 | -------------------------------------------------------------------------------- /lib/secret_hub/version.rb: -------------------------------------------------------------------------------- 1 | module SecretHub 2 | VERSION = '0.2.2' 3 | end 4 | -------------------------------------------------------------------------------- /spec/approvals/cli/repo/list/ok: -------------------------------------------------------------------------------- 1 | matz/ruby: 2 | - PASSWORD 3 | - SECRET 4 | -------------------------------------------------------------------------------- /spec/approvals/cli/exception: -------------------------------------------------------------------------------- 1 | SecretHub::APIError 2 | [404] not found 3 | guido/python: 4 | -------------------------------------------------------------------------------- /.codespellrc: -------------------------------------------------------------------------------- 1 | [codespell] 2 | skip = coverage,tmp,Gemfile.lock,sublime* 3 | ; ignore-words-list = rouge 4 | -------------------------------------------------------------------------------- /spec/fake_public_key.rb: -------------------------------------------------------------------------------- 1 | def fake_public_key 2 | 'MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=' 3 | end 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /tmp 2 | /coverage 3 | /dev 4 | /gems 5 | /Gemfile.lock 6 | /secrethub.yml 7 | /debug.runfile 8 | *.gem -------------------------------------------------------------------------------- /spec/approvals/cli/bulk/clean: -------------------------------------------------------------------------------- 1 | user/repo 2 | user/array-repo 3 | delete PASSWORD 4 | OK 5 | delete SECRET 6 | OK 7 | -------------------------------------------------------------------------------- /spec/approvals/cli/bulk/list: -------------------------------------------------------------------------------- 1 | user/repo: 2 | - PASSWORD 3 | - SECRET 4 | user/array-repo: 5 | - PASSWORD 6 | - SECRET 7 | -------------------------------------------------------------------------------- /spec/spec_mixin.rb: -------------------------------------------------------------------------------- 1 | module SpecMixin 2 | def reset_tmp_dir 3 | system 'cp spec/fixtures/*.yml tmp/' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/approvals/cli/bulk/save-only: -------------------------------------------------------------------------------- 1 | user/repo 2 | save SECRET 3 | OK 4 | save PASSWORD 5 | OK 6 | save SECRET_KEY 7 | OK 8 | -------------------------------------------------------------------------------- /lib/secret_hub.rb: -------------------------------------------------------------------------------- 1 | require 'secret_hub/version' 2 | require 'secret_hub/exceptions' 3 | require 'secret_hub/github_client' 4 | require 'secret_hub/config' 5 | -------------------------------------------------------------------------------- /spec/approvals/cli/bulk/clean-dry: -------------------------------------------------------------------------------- 1 | user/repo 2 | user/array-repo 3 | delete PASSWORD 4 | OK 5 | delete SECRET 6 | OK 7 | 8 | Dry run, nothing happened 9 | -------------------------------------------------------------------------------- /spec/approvals/cli/org/usage: -------------------------------------------------------------------------------- 1 | Usage: 2 | secrethub org list ORG 3 | secrethub org save ORG KEY [VALUE] 4 | secrethub org delete ORG KEY 5 | secrethub org (-h|--help) 6 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | secrethub: 3 | build: . 4 | image: dannyben/secrethub 5 | environment: 6 | GITHUB_ACCESS_TOKEN: 7 | volumes: 8 | - .:/app 9 | -------------------------------------------------------------------------------- /spec/approvals/cli/repo/usage: -------------------------------------------------------------------------------- 1 | Usage: 2 | secrethub repo list REPO 3 | secrethub repo save REPO KEY [VALUE] 4 | secrethub repo delete REPO KEY 5 | secrethub repo (-h|--help) 6 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-performance 3 | - rubocop-rspec 4 | 5 | inherit_gem: 6 | rentacop: 7 | - rentacop.yml 8 | - rspec.yml 9 | 10 | AllCops: 11 | TargetRubyVersion: 3.0 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM dannyben/alpine-ruby:3.2.2 2 | 3 | RUN apk add --no-cache libsodium-dev 4 | RUN gem install secret_hub --version 0.2.2 5 | 6 | WORKDIR /app 7 | VOLUME /app 8 | 9 | ENTRYPOINT ["secrethub"] 10 | -------------------------------------------------------------------------------- /spec/fixtures/secrets.yml: -------------------------------------------------------------------------------- 1 | --- 2 | default: &default 3 | SECRET: there is no spoon 4 | PASSWORD: 5 | 6 | user/repo: 7 | <<: *default 8 | SECRET_KEY: 9 | 10 | user/array-repo: 11 | - SECRET_KEY 12 | - SECRET_ID 13 | -------------------------------------------------------------------------------- /spec/approvals/cli/bulk/show: -------------------------------------------------------------------------------- 1 | user/repo: 2 | SECRET: *********** spoon 3 | PASSWORD: *****0rd 4 | SECRET_KEY: ******************m-54mp13-k3y 5 | user/array-repo: 6 | SECRET_KEY: ******************m-54mp13-k3y 7 | SECRET_ID: *MISSING* 8 | -------------------------------------------------------------------------------- /spec/approvals/cli/bulk/show-visible: -------------------------------------------------------------------------------- 1 | user/repo: 2 | SECRET: there is no spoon 3 | PASSWORD: p4ssw0rd 4 | SECRET_KEY: 7h15-15-50m3-24nd0m-54mp13-k3y 5 | user/array-repo: 6 | SECRET_KEY: 7h15-15-50m3-24nd0m-54mp13-k3y 7 | SECRET_ID: *MISSING* 8 | -------------------------------------------------------------------------------- /spec/approvals/cli/bulk/save: -------------------------------------------------------------------------------- 1 | user/repo 2 | save SECRET 3 | OK 4 | save PASSWORD 5 | OK 6 | save SECRET_KEY 7 | OK 8 | user/array-repo 9 | save SECRET_KEY 10 | OK 11 | save SECRET_ID 12 | MISSING 13 | 14 | Skipped 1 missing secrets 15 | -------------------------------------------------------------------------------- /spec/approvals/cli/commands: -------------------------------------------------------------------------------- 1 | GitHub Secret Manager 2 | 3 | Commands: 4 | repo Manage repository secrets 5 | org Manage organization secrets 6 | bulk Manage multiple secrets in multiple repositories 7 | 8 | Run secrethub COMMAND --help for command specific help 9 | -------------------------------------------------------------------------------- /spec/approvals/cli/bulk/usage: -------------------------------------------------------------------------------- 1 | Usage: 2 | secrethub bulk init [CONFIG] 3 | secrethub bulk show [CONFIG --visible] 4 | secrethub bulk list [CONFIG] 5 | secrethub bulk save [CONFIG --clean --dry --only REPO] 6 | secrethub bulk clean [CONFIG --dry] 7 | secrethub bulk (-h|--help) 8 | -------------------------------------------------------------------------------- /lib/secret_hub/commands/base.rb: -------------------------------------------------------------------------------- 1 | require 'mister_bin' 2 | require 'colsole' 3 | require 'lp' 4 | 5 | module SecretHub 6 | module Commands 7 | class Base < MisterBin::Command 8 | include Colsole 9 | 10 | def github 11 | @github ||= GitHubClient.new 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/approvals/cli/bulk/save-clean: -------------------------------------------------------------------------------- 1 | user/repo 2 | save SECRET 3 | OK 4 | save PASSWORD 5 | OK 6 | save SECRET_KEY 7 | OK 8 | user/array-repo 9 | save SECRET_KEY 10 | OK 11 | save SECRET_ID 12 | MISSING 13 | delete PASSWORD 14 | OK 15 | delete SECRET 16 | OK 17 | 18 | Skipped 1 missing secrets 19 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'debug' 4 | gem 'lp' 5 | gem 'pretty_trace' 6 | gem 'puma', require: false 7 | gem 'rentacop', require: false 8 | gem 'rspec' 9 | gem 'rspec_approvals' 10 | gem 'runfile', require: false 11 | gem 'runfile-tasks', require: false 12 | gem 'simplecov' 13 | gem 'sinatra', require: false 14 | 15 | gemspec 16 | -------------------------------------------------------------------------------- /spec/approvals/cli/bulk/save-dry: -------------------------------------------------------------------------------- 1 | user/repo 2 | save SECRET 3 | OK 4 | save PASSWORD 5 | OK 6 | save SECRET_KEY 7 | OK 8 | user/array-repo 9 | save SECRET_KEY 10 | OK 11 | save SECRET_ID 12 | MISSING 13 | delete PASSWORD 14 | OK 15 | delete SECRET 16 | OK 17 | 18 | Skipped 1 missing secrets 19 | Dry run, nothing happened 20 | -------------------------------------------------------------------------------- /bin/secrethub: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'secret_hub' 3 | require 'secret_hub/cli' 4 | require 'colsole' 5 | include Colsole 6 | 7 | router = SecretHub::CLI.router 8 | 9 | begin 10 | exit router.run ARGV 11 | rescue Interrupt 12 | say "\nGoodbye" 13 | exit 1 14 | rescue => e 15 | puts e.backtrace.reverse if ENV['DEBUG'] 16 | say! "rib` #{e.class}`" 17 | say! e.message 18 | exit 1 19 | end 20 | -------------------------------------------------------------------------------- /lib/secret_hub/exceptions.rb: -------------------------------------------------------------------------------- 1 | module SecretHub 2 | SecretHubError = Class.new StandardError 3 | ConfigurationError = Class.new SecretHubError 4 | InvalidInput = Class.new SecretHubError 5 | 6 | class APIError < SecretHubError 7 | attr_reader :response 8 | 9 | def initialize(response) 10 | @response = response 11 | super "[#{response.code}] #{response.body}" 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/secret_hub/sodium.rb: -------------------------------------------------------------------------------- 1 | require 'base64' 2 | require 'rbnacl' 3 | 4 | module SecretHub 5 | module Sodium 6 | def encrypt(secret, public_key) 7 | key = Base64.decode64 public_key 8 | public_key = RbNaCl::PublicKey.new key 9 | 10 | box = RbNaCl::Boxes::Sealed.from_public_key public_key 11 | encrypted_secret = box.encrypt secret 12 | 13 | Base64.strict_encode64 encrypted_secret 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/secret_hub/bin_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'bin/secret_hub' do 4 | subject { CLI.router } 5 | 6 | it 'shows list of commands' do 7 | expect { subject.run }.to output_approval('cli/commands') 8 | end 9 | 10 | context 'when an exception occurs' do 11 | it 'errors gracefully' do 12 | expect(`bin/secrethub repo list guido/python 2>&1`).to match_approval('cli/exception') 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/secret_hub/config-template.yml: -------------------------------------------------------------------------------- 1 | # Ignored keys 2 | # Keys that do not include '/' will be ignored 3 | # Can be used to set some reusable YAML anchors 4 | docker: &docker 5 | DOCKER_USER: 6 | DOCKER_PASSWORD: 7 | 8 | # Array syntax 9 | # All secrets must be defined as environment variables 10 | user/repo: 11 | - SECRET 12 | - PASSWORD 13 | 14 | # Hash syntax 15 | # Empty secrets will be loaded from environment variables 16 | user/another-repo: 17 | <<: *docker 18 | SECRET: 19 | PASSWORD: p4ssw0rd -------------------------------------------------------------------------------- /spec/approvals/cli/bulk/init-file: -------------------------------------------------------------------------------- 1 | # Ignored keys 2 | # Keys that do not include '/' will be ignored 3 | # Can be used to set some reusable YAML anchors 4 | docker: &docker 5 | DOCKER_USER: 6 | DOCKER_PASSWORD: 7 | 8 | # Array syntax 9 | # All secrets must be defined as environment variables 10 | user/repo: 11 | - SECRET 12 | - PASSWORD 13 | 14 | # Hash syntax 15 | # Empty secrets will be loaded from environment variables 16 | user/another-repo: 17 | <<: *docker 18 | SECRET: 19 | PASSWORD: p4ssw0rd -------------------------------------------------------------------------------- /lib/secret_hub/refinements/string_obfuscation.rb: -------------------------------------------------------------------------------- 1 | require 'string-obfuscator' 2 | 3 | module SecretHub 4 | module StringObfuscation 5 | refine String do 6 | def obfuscate 7 | text = dup 8 | trim = false 9 | 10 | if text.size > 40 11 | trim = true 12 | text = text[0..40] 13 | end 14 | 15 | result = StringObfuscator.obfuscate text, 16 | percent: 60, 17 | min_obfuscated_length: 5 18 | 19 | trim ? "#{result}..." : result 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /Runfile: -------------------------------------------------------------------------------- 1 | require 'debug' 2 | require 'secret_hub' 3 | require 'secret_hub/version' 4 | 5 | title "SecretHub Developer Toolbelt" 6 | summary "Runfile tasks for building the SecretHub gem" 7 | version SecretHub::VERSION 8 | 9 | import_gem 'runfile-tasks/gem' 10 | import_gem 'runfile-tasks/docker', image: 'dannyben/secrethub', version: SecretHub::VERSION 11 | import 'debug' 12 | 13 | help "Run test mock server" 14 | usage "mockserver" 15 | action :mockserver do |args| 16 | ENV['SINATRA_ACTIVESUPPORT_WARNING'] = 'false' 17 | exec "bundle exec ruby spec/mock_api/server.rb" 18 | end 19 | -------------------------------------------------------------------------------- /spec/secret_hub/refinements/string_obfuscation_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe StringObfuscation do 4 | using described_class 5 | 6 | describe '#obfuscate' do 7 | it 'obfuscates a string' do 8 | expect('this is a secret'.obfuscate).to eq '**********secret' 9 | end 10 | 11 | context 'when the string is longer than 40 characters' do 12 | it 'trims it before obfuscation' do 13 | text = 'this is a long text, something like an encrypted key' 14 | expect(text.obfuscate).to eq '*************************thing like an en...' 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/secret_hub/cli.rb: -------------------------------------------------------------------------------- 1 | require 'mister_bin' 2 | require 'secret_hub/commands/base' 3 | require 'secret_hub/commands/repo' 4 | require 'secret_hub/commands/bulk' 5 | require 'secret_hub/commands/org' 6 | 7 | module SecretHub 8 | class CLI 9 | def self.router 10 | router = MisterBin::Runner.new version: VERSION, 11 | header: 'GitHub Secret Manager', 12 | footer: 'Run m`secrethub COMMAND --help` for command specific help' 13 | 14 | router.route 'repo', to: Commands::Repo 15 | router.route 'org', to: Commands::Org 16 | router.route 'bulk', to: Commands::Bulk 17 | 18 | router 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | pull_request: 4 | push: { branches: master } 5 | 6 | jobs: 7 | test: 8 | name: Ruby ${{ matrix.ruby }} 9 | 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | matrix: { ruby: ['3.0', '3.1', '3.2', '3.3'] } 14 | 15 | steps: 16 | - name: Check out code 17 | uses: actions/checkout@v3 18 | 19 | - name: Install OS dependencies 20 | run: sudo apt-get -y install libyaml-dev 21 | 22 | - name: Setup Ruby 23 | uses: ruby/setup-ruby@v1 24 | with: 25 | ruby-version: '${{ matrix.ruby }}' 26 | bundler-cache: true 27 | 28 | - name: Start mock server 29 | run: | 30 | nohup bundle exec run mockserver & 31 | sleep 2 32 | 33 | - name: Run tests 34 | run: bundle exec rspec 35 | -------------------------------------------------------------------------------- /spec/approvals/cli/repo/help: -------------------------------------------------------------------------------- 1 | Manage repository secrets 2 | 3 | Usage: 4 | secrethub repo list REPO 5 | secrethub repo save REPO KEY [VALUE] 6 | secrethub repo delete REPO KEY 7 | secrethub repo (-h|--help) 8 | 9 | Commands: 10 | list 11 | Show all repository secrets 12 | 13 | save 14 | Create or update a repository secret 15 | 16 | delete 17 | Delete a repository secret 18 | 19 | Options: 20 | -h --help 21 | Show this help 22 | 23 | Parameters: 24 | REPO 25 | Full name of the GitHub repository (user/repo) 26 | 27 | KEY 28 | The name of the secret 29 | 30 | VALUE 31 | The plain text secret value. If not provided, it is expected to be set as an 32 | environment variable 33 | 34 | Examples: 35 | secrethub repo list me/myrepo 36 | secrethub repo save me/myrepo PASSWORD 37 | secrethub repo save me/myrepo PASSWORD s3cr3t 38 | secrethub repo delete me/myrepo PASSWORD 39 | -------------------------------------------------------------------------------- /spec/approvals/cli/org/help: -------------------------------------------------------------------------------- 1 | Manage organization secrets 2 | 3 | Usage: 4 | secrethub org list ORG 5 | secrethub org save ORG KEY [VALUE] 6 | secrethub org delete ORG KEY 7 | secrethub org (-h|--help) 8 | 9 | Commands: 10 | list 11 | Show all organization secrets 12 | 13 | save 14 | Create or update an organization secret (with private repositories 15 | visibility) 16 | 17 | delete 18 | Delete an organization secret 19 | 20 | Options: 21 | -h --help 22 | Show this help 23 | 24 | Parameters: 25 | ORG 26 | Name of the organization 27 | 28 | KEY 29 | The name of the secret 30 | 31 | VALUE 32 | The plain text secret value. If not provided, it is expected to be set as an 33 | environment variable 34 | 35 | Examples: 36 | secrethub org list myorg 37 | secrethub org save myorg PASSWORD 38 | secrethub org save myorg PASSWORD s3cr3t 39 | secrethub org delete myorg PASSWORD 40 | -------------------------------------------------------------------------------- /.github/workflows/docker-latest.yml: -------------------------------------------------------------------------------- 1 | name: Docker build (version + latest) 2 | on: 3 | push: { tags: 'v[0-9]+.[0-9]+.[0-9]+' } 4 | 5 | jobs: 6 | docker: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v4 11 | - name: Extract tag 12 | run: echo "TAG=${GITHUB_REF#refs/*/v}" >> $GITHUB_ENV 13 | - name: Set up QEMU 14 | uses: docker/setup-qemu-action@v3 15 | - name: Set up Docker Buildx 16 | uses: docker/setup-buildx-action@v3 17 | - name: Login to Docker Hub 18 | uses: docker/login-action@v3 19 | with: 20 | username: ${{ secrets.DOCKERHUB_USERNAME }} 21 | password: ${{ secrets.DOCKERHUB_TOKEN }} 22 | - name: Build and push 23 | uses: docker/build-push-action@v5 24 | with: 25 | context: . 26 | platforms: linux/amd64,linux/arm64 27 | push: true 28 | tags: dannyben/secrethub,dannyben/secrethub:${{ env.TAG }} 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Danny Ben Shitrit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /spec/secret_hub/sodium_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Sodium do 4 | subject { Class.new { include Sodium }.new } 5 | 6 | let(:secret) { 'there is no spoon' } 7 | 8 | describe '#encrypt' do 9 | # Prepare a public/private key pair 10 | let(:private_key) { RbNaCl::PrivateKey.generate } 11 | let(:public_key) { private_key.public_key } 12 | 13 | # Base64 encode the public key - this is how GitHub API returns it 14 | let(:base64_encoded_public_key) { Base64.encode64 public_key } 15 | 16 | it 'returns an encrypted and base64-encoded string' do 17 | # Encrypt, using the method under test 18 | babse64_encrypted = subject.encrypt secret, base64_encoded_public_key 19 | 20 | # Base64 decode it, since the method encodes it before transport to 21 | # GitHub 22 | encrypted = Base64.strict_decode64 babse64_encrypted 23 | 24 | # Decrypt it using our matching private key 25 | box = RbNaCl::Boxes::Sealed.from_private_key private_key 26 | plain = box.decrypt encrypted 27 | 28 | # Compare it to the original plain secret. Boom. 29 | expect(plain).to eq secret 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/secret_hub/config_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Config do 4 | subject { described_class.load config_file } 5 | 6 | let(:config_file) { 'spec/fixtures/secrets.yml' } 7 | 8 | describe '::load' do 9 | it 'loads config from file' do 10 | expect(subject.to_h.keys).to eq ['user/repo', 'user/array-repo'] 11 | end 12 | 13 | context 'when the file does not exist' do 14 | let(:config_file) { 'no-such-config-file.yml' } 15 | 16 | it 'raises ConfigurationError' do 17 | expect { described_class.load config_file }.to raise_error(ConfigurationError) 18 | end 19 | end 20 | end 21 | 22 | describe '#each' do 23 | it 'yields repos and their secrets hash' do 24 | result = {} 25 | 26 | subject.each do |repo, secrets| 27 | result[repo] = secrets 28 | end 29 | 30 | expect(result).to eq subject.to_h 31 | end 32 | end 33 | 34 | describe '#each_repo' do 35 | it 'yields repo names' do 36 | result = [] 37 | 38 | subject.each_repo do |repo| 39 | result << repo 40 | end 41 | 42 | expect(result).to eq subject.to_h.keys 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # Simplecov 2 | require 'simplecov' 3 | SimpleCov.start 4 | 5 | # Dependencies 6 | require 'rubygems' 7 | require 'bundler' 8 | Bundler.require :default, :development 9 | 10 | # Our gem 11 | require 'secret_hub' 12 | require 'secret_hub/cli' 13 | include SecretHub 14 | 15 | # Our spec helpers 16 | require_relative 'fake_public_key' 17 | require_relative 'spec_mixin' 18 | include SpecMixin 19 | 20 | # Use our mock server instead of the actual GitHub API 21 | ENV['SECRET_HUB_API_BASE'] = 'http://localhost:3000' 22 | begin 23 | HTTParty.get 'http://localhost:3000' 24 | rescue Errno::ECONNREFUSED 25 | abort "\n==> ERROR: Please start the mock server using `run mockserver`\n\n" 26 | end 27 | 28 | # Dummy secrets for testing 29 | ENV['GITHUB_ACCESS_TOKEN'] = 'who took my token?' 30 | ENV['SECRET'] = 'there is no spoon' 31 | ENV['PASSWORD'] = 'p4ssw0rd' 32 | ENV['SECRET_KEY'] = '7h15-15-50m3-24nd0m-54mp13-k3y' 33 | 34 | # Make some place for testing 35 | system 'mkdir tmp' unless Dir.exist? 'tmp' 36 | 37 | # Ensure terminal width consistency across tests 38 | ENV['COLUMNS'] = '80' 39 | ENV['LINES'] = '30' 40 | 41 | # Configure rspec 42 | RSpec.configure do |c| 43 | c.include SpecMixin 44 | end 45 | -------------------------------------------------------------------------------- /lib/secret_hub/config.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | 3 | module SecretHub 4 | class Config 5 | attr_reader :data 6 | 7 | def self.load(config_file) 8 | raise ConfigurationError, "Config file not found #{config_file}" unless File.exist? config_file 9 | 10 | new YAML.load_file config_file, aliases: true 11 | rescue ArgumentError 12 | # :nocov: 13 | new YAML.load_file config_file 14 | # :nocov: 15 | end 16 | 17 | def initialize(data) 18 | @data = data 19 | end 20 | 21 | def to_h 22 | @to_h ||= to_h! 23 | end 24 | 25 | def each(&block) 26 | to_h.each(&block) 27 | end 28 | 29 | def each_repo(&block) 30 | to_h.keys.each(&block) 31 | end 32 | 33 | private 34 | 35 | def to_h! 36 | result = {} 37 | data.each do |repo, secrets| 38 | next unless repo.include? '/' 39 | 40 | result[repo] = resolve_secrets secrets 41 | end 42 | result 43 | end 44 | 45 | def resolve_secrets(secrets) 46 | secrets ||= [] 47 | 48 | case secrets 49 | when Hash 50 | secrets.to_h { |key, value| [key, value || ENV[key]] } 51 | when Array 52 | secrets.to_h { |key| [key, ENV[key]] } 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /secret_hub.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('lib', __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'secret_hub/version' 4 | 5 | Gem::Specification.new do |s| 6 | s.name = 'secret_hub' 7 | s.version = SecretHub::VERSION 8 | s.summary = 'Manage GitHub secrets over multiple repositories' 9 | s.description = 'Command line interface for managing GitHub secrets in bulk' 10 | s.authors = ['Danny Ben Shitrit'] 11 | s.email = 'db@dannyben.com' 12 | s.files = Dir['README.md', 'lib/**/*.*'] 13 | s.executables = ['secrethub'] 14 | s.homepage = 'https://github.com/dannyben/secret_hub' 15 | s.license = 'MIT' 16 | s.required_ruby_version = '>= 3.0' 17 | 18 | s.add_dependency 'colsole', '>= 0.8.1', '< 2' 19 | s.add_dependency 'httparty', '~> 0.21' 20 | s.add_dependency 'lp', '~> 0.2' 21 | s.add_dependency 'mister_bin', '~> 0.7.3' 22 | s.add_dependency 'rackup', '~> 2.1' 23 | s.add_dependency 'rbnacl', '~> 7.1' 24 | s.add_dependency 'string-obfuscator', '~> 0.1' 25 | 26 | # REMOVE ME 27 | s.add_dependency 'bigdecimal', '>= 0' # to address ruby warning by multi_xml 28 | s.add_dependency 'csv', '>= 0' # to address ruby warning by httparty 29 | 30 | s.metadata['rubygems_mfa_required'] = 'true' 31 | end 32 | -------------------------------------------------------------------------------- /spec/approvals/cli/bulk/help: -------------------------------------------------------------------------------- 1 | Manage multiple secrets in multiple repositories 2 | 3 | Usage: 4 | secrethub bulk init [CONFIG] 5 | secrethub bulk show [CONFIG --visible] 6 | secrethub bulk list [CONFIG] 7 | secrethub bulk save [CONFIG --clean --dry --only REPO] 8 | secrethub bulk clean [CONFIG --dry] 9 | secrethub bulk (-h|--help) 10 | 11 | Commands: 12 | init 13 | Create a sample configuration file in the current directory 14 | 15 | show 16 | Show the configuration file 17 | 18 | save 19 | Save multiple secrets to multiple repositories 20 | 21 | clean 22 | Delete secrets from multiple repositories unless they are specified in the 23 | config file 24 | 25 | list 26 | Show all secrets in all repositories 27 | 28 | Options: 29 | -c, --clean 30 | Also delete any other secret not defined in the configuration file 31 | 32 | -v, --visible 33 | Also show secret values 34 | 35 | -d, --dry 36 | Dry run 37 | 38 | -o, --only REPO 39 | Save all secrets to a single repository from the configuration file 40 | 41 | -h --help 42 | Show this help 43 | 44 | Parameters: 45 | CONFIG 46 | Path to the configuration file [default: secrethub.yml] 47 | 48 | Examples: 49 | secrethub bulk init 50 | secrethub bulk show --visible 51 | secrethub bulk clean 52 | secrethub bulk list mysecrets.yml 53 | secrethub bulk save mysecrets.yml --dry 54 | secrethub bulk save --clean 55 | secrethub bulk save --only me/my-important-repo 56 | -------------------------------------------------------------------------------- /spec/secret_hub/commands/org_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Commands::Org do 4 | context 'without arguments' do 5 | it 'shows short usage' do 6 | expect { subject.execute %w[org] }.to output_approval('cli/org/usage') 7 | end 8 | end 9 | 10 | context 'with --help' do 11 | it 'shows long usage' do 12 | expect { subject.execute %w[org --help] }.to output_approval('cli/org/help') 13 | end 14 | end 15 | 16 | describe 'list ORG' do 17 | it 'shows list of secrets' do 18 | expect { subject.execute %w[org list matz] }.to output_approval('cli/org/list/ok') 19 | end 20 | end 21 | 22 | describe 'save ORG KEY VALUE' do 23 | it 'saves the secret' do 24 | expect { subject.execute %w[org save matz PASSWORD p4ssw0rd] }.to output_approval('cli/org/save/ok') 25 | end 26 | end 27 | 28 | describe 'save ORG KEY' do 29 | context 'when the value exists in the environemnt' do 30 | before { ENV['PASSWORD'] = 's3cr3tz' } 31 | after { ENV['PASSWORD'] = nil } 32 | 33 | it 'saves the secret' do 34 | expect { subject.execute %w[org save matz PASSWORD] }.to output_approval('cli/org/save/ok') 35 | end 36 | end 37 | 38 | context 'when the value does not exist in the environemnt' do 39 | it 'raises InvalidInput' do 40 | expect { subject.execute %w[org save matz PASSWORD] }.to raise_error(InvalidInput) 41 | end 42 | end 43 | end 44 | 45 | describe 'delete ORG KEY' do 46 | it 'deletes the secret' do 47 | expect { subject.execute %w[org delete matz PASSWORD] }.to output_approval('cli/org/delete/ok') 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/secret_hub/commands/repo_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Commands::Repo do 4 | context 'without arguments' do 5 | it 'shows short usage' do 6 | expect { subject.execute %w[repo] }.to output_approval('cli/repo/usage') 7 | end 8 | end 9 | 10 | context 'with --help' do 11 | it 'shows long usage' do 12 | expect { subject.execute %w[repo --help] }.to output_approval('cli/repo/help') 13 | end 14 | end 15 | 16 | describe 'list REPO' do 17 | it 'shows list of secrets' do 18 | expect { subject.execute %w[repo list matz/ruby] }.to output_approval('cli/repo/list/ok') 19 | end 20 | end 21 | 22 | describe 'save REPO KEY' do 23 | context 'when the value exists in the environemnt' do 24 | before { ENV['PASSWORD'] = 's3cr3tz' } 25 | after { ENV['PASSWORD'] = nil } 26 | 27 | it 'saves the secret' do 28 | expect { subject.execute %w[repo save matz/ruby PASSWORD] }.to output_approval('cli/repo/save/ok') 29 | end 30 | end 31 | 32 | context 'when the value does not exist in the environemnt' do 33 | it 'raises InvalidInput' do 34 | expect { subject.execute %w[repo save matz/ruby PASSWORD] }.to raise_error(InvalidInput) 35 | end 36 | end 37 | end 38 | 39 | describe 'save REPO KEY VALUE' do 40 | it 'saves the secret' do 41 | expect { subject.execute %w[repo save matz/ruby PASSWORD p4ssw0rd] }.to output_approval('cli/repo/save/ok') 42 | end 43 | end 44 | 45 | describe 'delete REPO KEY' do 46 | it 'deletes the secret' do 47 | expect { subject.execute %w[repo delete matz/ruby PASSWORD] }.to output_approval('cli/repo/delete/ok') 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/secret_hub/git_hub_client_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe GitHubClient do 4 | let(:repo) { 'matz/ruby' } 5 | let(:non_repo) { 'guido/python' } 6 | let(:secret) { 'there is no spoon' } 7 | 8 | describe '#public_key' do 9 | it 'returns a hash with the key and key_id' do 10 | expect(subject.public_key repo) 11 | .to eq({ 'key' => fake_public_key, 'key_id' => 'some-key-id' }) 12 | end 13 | 14 | context 'when an error occurs' do 15 | it 'raises APIError' do 16 | expect { subject.public_key non_repo }.to raise_error(APIError) 17 | end 18 | end 19 | end 20 | 21 | describe '#secrets' do 22 | it 'returns an array of secret keys' do 23 | expect(subject.secrets repo).to eq(%w[PASSWORD SECRET]) 24 | end 25 | 26 | context 'when an error occurs' do 27 | it 'raises APIError' do 28 | expect { subject.secrets non_repo }.to raise_error(APIError) 29 | end 30 | end 31 | end 32 | 33 | describe '#put_secret' do 34 | it 'creates or updates a secret' do 35 | expect(subject.put_secret repo, 'SECRET', secret).to be true 36 | end 37 | 38 | context 'when an error occurs' do 39 | it 'raises APIError' do 40 | expect { subject.put_secret non_repo, 'SECRET', secret }.to raise_error(APIError) 41 | end 42 | end 43 | end 44 | 45 | describe '#delete_secret' do 46 | it 'deletes a secret' do 47 | expect(subject.delete_secret repo, 'SECRET').to be true 48 | end 49 | 50 | context 'when an error occurs' do 51 | it 'raises APIError' do 52 | expect { subject.delete_secret non_repo, 'SECRET' }.to raise_error(APIError) 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /schemas/secrethub.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "title": "repository", 4 | "description": "A repository of the current user\nhttps://github.com/DannyBen/secret_hub#bulk-operations", 5 | "type": "object", 6 | "patternProperties": { 7 | ".": { 8 | "title": "secrets", 9 | "description": "Secrets of the current repository\nhttps://github.com/DannyBen/secret_hub#bulk-operations", 10 | "oneOf": [ 11 | { 12 | "type": "array", 13 | "minItems": 1, 14 | "uniqueItems": true, 15 | "items": { 16 | "description": "A secret of the current repository\nhttps://github.com/DannyBen/secret_hub#bulk-operations", 17 | "type": "string", 18 | "minLength": 1, 19 | "examples": [ 20 | "SECRET", 21 | "PASSWORD" 22 | ] 23 | } 24 | }, 25 | { 26 | "title": "secret", 27 | "type": "object", 28 | "patternProperties": { 29 | ".": { 30 | "description": "A secret of the current repository\nhttps://github.com/DannyBen/secret_hub#bulk-operations", 31 | "type": [ 32 | "string", 33 | "null" 34 | ] 35 | } 36 | } 37 | } 38 | ] 39 | } 40 | }, 41 | "additionalProperties": false 42 | } -------------------------------------------------------------------------------- /lib/secret_hub/commands/org.rb: -------------------------------------------------------------------------------- 1 | module SecretHub 2 | module Commands 3 | class Org < Base 4 | summary 'Manage organization secrets' 5 | 6 | usage 'secrethub org list ORG' 7 | usage 'secrethub org save ORG KEY [VALUE]' 8 | usage 'secrethub org delete ORG KEY' 9 | usage 'secrethub org (-h|--help)' 10 | 11 | command 'list', 'Show all organization secrets' 12 | command 'save', 'Create or update an organization secret (with private repositories visibility)' 13 | command 'delete', 'Delete an organization secret' 14 | 15 | param 'ORG', 'Name of the organization' 16 | param 'KEY', 'The name of the secret' 17 | param 'VALUE', 'The plain text secret value. If not provided, it is expected to be set as an environment variable' 18 | 19 | example 'secrethub org list myorg' 20 | example 'secrethub org save myorg PASSWORD' 21 | example 'secrethub org save myorg PASSWORD s3cr3t' 22 | example 'secrethub org delete myorg PASSWORD' 23 | 24 | def list_command 25 | say "b`#{org}`:" 26 | github.org_secrets(org).each do |secret| 27 | say "- m`#{secret}`" 28 | end 29 | end 30 | 31 | def save_command 32 | github.put_org_secret org, key, value 33 | say "Saved b`#{org}` m`#{key}`" 34 | end 35 | 36 | def delete_command 37 | github.delete_org_secret org, key 38 | say "Deleted b`#{org}` m`#{key}`" 39 | end 40 | 41 | private 42 | 43 | def org 44 | args['ORG'] 45 | end 46 | 47 | def key 48 | args['KEY'] 49 | end 50 | 51 | def value 52 | result = args['VALUE'] || ENV[key] 53 | unless result 54 | raise InvalidInput, 55 | "Please provide a value, either in the command line or in the environment variable '#{key}'" 56 | end 57 | 58 | result 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/secret_hub/commands/repo.rb: -------------------------------------------------------------------------------- 1 | module SecretHub 2 | module Commands 3 | class Repo < Base 4 | summary 'Manage repository secrets' 5 | 6 | usage 'secrethub repo list REPO' 7 | usage 'secrethub repo save REPO KEY [VALUE]' 8 | usage 'secrethub repo delete REPO KEY' 9 | usage 'secrethub repo (-h|--help)' 10 | 11 | command 'list', 'Show all repository secrets' 12 | command 'save', 'Create or update a repository secret' 13 | command 'delete', 'Delete a repository secret' 14 | 15 | param 'REPO', 'Full name of the GitHub repository (user/repo)' 16 | param 'KEY', 'The name of the secret' 17 | param 'VALUE', 'The plain text secret value. If not provided, it is expected to be set as an environment variable' 18 | 19 | example 'secrethub repo list me/myrepo' 20 | example 'secrethub repo save me/myrepo PASSWORD' 21 | example 'secrethub repo save me/myrepo PASSWORD s3cr3t' 22 | example 'secrethub repo delete me/myrepo PASSWORD' 23 | 24 | def list_command 25 | say "b`#{repo}`:" 26 | github.secrets(repo).each do |secret| 27 | say "- m`#{secret}`" 28 | end 29 | end 30 | 31 | def save_command 32 | github.put_secret repo, key, value 33 | say "Saved b`#{repo}` m`#{key}`" 34 | end 35 | 36 | def delete_command 37 | github.delete_secret repo, key 38 | say "Deleted b`#{repo}` m`#{key}`" 39 | end 40 | 41 | private 42 | 43 | def repo 44 | args['REPO'] 45 | end 46 | 47 | def key 48 | args['KEY'] 49 | end 50 | 51 | def value 52 | result = args['VALUE'] || ENV[key] 53 | unless result 54 | raise InvalidInput, 55 | "Please provide a value, either in the command line or in the environment variable '#{key}'" 56 | end 57 | 58 | result 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/mock_api/server.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra' 2 | require 'debug' 3 | require 'yaml' 4 | require_relative '../fake_public_key' 5 | 6 | set :port, 3000 7 | set :bind, '0.0.0.0' 8 | 9 | def json(hash) 10 | content_type :json 11 | JSON.pretty_generate hash 12 | end 13 | 14 | # Handshake 15 | get '/' do 16 | json mockserver: :online 17 | end 18 | 19 | # GET Public Key 20 | get '/repos/:owner/:repo/actions/secrets/public-key' do 21 | if %w[matz user].include? params[:owner] 22 | json key: fake_public_key, key_id: 'some-key-id' 23 | else 24 | halt 404, 'not found' 25 | end 26 | end 27 | 28 | # GET Org Public Key 29 | get '/orgs/:org/actions/secrets/public-key' do 30 | if %w[matz user].include? params[:org] 31 | json key: fake_public_key, key_id: 'some-key-id' 32 | else 33 | halt 404, 'not found' 34 | end 35 | end 36 | 37 | # GET Secrets 38 | get '/repos/:owner/:repo/actions/secrets' do 39 | if %w[matz user].include? params[:owner] 40 | json secrets: [{ name: 'PASSWORD' }, { name: 'SECRET' }] 41 | else 42 | halt 404, 'not found' 43 | end 44 | end 45 | 46 | # GET Org Secrets 47 | get '/orgs/:org/actions/secrets' do 48 | if %w[matz user].include? params[:org] 49 | json secrets: [{ name: 'PASSWORD' }, { name: 'SECRET' }] 50 | else 51 | halt 404, 'not found' 52 | end 53 | end 54 | 55 | # PUT Secret 56 | put '/repos/:owner/:repo/actions/secrets/:name' do 57 | if %w[matz user].include? params[:owner] 58 | status 200 59 | '' 60 | else 61 | halt 500, 'some error' 62 | end 63 | end 64 | 65 | # PUT Org Secret 66 | put '/orgs/:org/actions/secrets/:name' do 67 | if %w[matz user].include? params[:org] 68 | status 200 69 | '' 70 | else 71 | halt 500, 'some error' 72 | end 73 | end 74 | 75 | # DELETE Secret 76 | delete '/repos/:owner/:repo/actions/secrets/:name' do 77 | if %w[matz user].include? params[:owner] 78 | status 200 79 | '' 80 | else 81 | halt 500, 'some error' 82 | end 83 | end 84 | 85 | # DELETE Org Secret 86 | delete '/orgs/:org/actions/secrets/:name' do 87 | if %w[matz user].include? params[:org] 88 | status 200 89 | '' 90 | else 91 | halt 500, 'some error' 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/secret_hub/commands/bulk_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Commands::Bulk do 4 | let(:config_file) { 'tmp/secrets.yml' } 5 | let(:github_client) { GitHubClient.new } 6 | 7 | context 'without arguments' do 8 | it 'shows short usage' do 9 | expect { subject.execute %w[bulk] }.to output_approval('cli/bulk/usage') 10 | end 11 | end 12 | 13 | context 'with --help' do 14 | it 'shows long usage' do 15 | expect { subject.execute %w[bulk --help] }.to output_approval('cli/bulk/help') 16 | end 17 | end 18 | 19 | describe 'init' do 20 | before { system "rm -f #{config_file}" } 21 | 22 | it 'creates a sample configuration file' do 23 | expect { subject.execute %W[bulk init #{config_file}] }.to output_approval('cli/bulk/init') 24 | expect(File).to exist(config_file) 25 | expect(File.read config_file).to match_approval('cli/bulk/init-file') 26 | end 27 | end 28 | 29 | describe 'list' do 30 | before { reset_tmp_dir } 31 | 32 | it 'shows all secrets for the configured repos' do 33 | expect { subject.execute %W[bulk list #{config_file}] }.to output_approval('cli/bulk/list') 34 | end 35 | end 36 | 37 | describe 'show' do 38 | before { reset_tmp_dir } 39 | 40 | it 'shows the local configuration file and obfuscated secrets' do 41 | expect { subject.execute %W[bulk show #{config_file}] }.to output_approval('cli/bulk/show') 42 | end 43 | 44 | describe '--visible' do 45 | it 'shows the local configuration file and revealed secrets' do 46 | expect { subject.execute %W[bulk show #{config_file} --visible] }.to output_approval('cli/bulk/show-visible') 47 | end 48 | end 49 | end 50 | 51 | describe 'save' do 52 | before { reset_tmp_dir } 53 | 54 | it 'updates all secrets for the configured repos' do 55 | expect { subject.execute %W[bulk save #{config_file}] }.to output_approval('cli/bulk/save') 56 | end 57 | 58 | describe '--clean' do 59 | it 'also deletes keys that are not configured' do 60 | expect { subject.execute %W[bulk save #{config_file} --clean] }.to output_approval('cli/bulk/save-clean') 61 | end 62 | end 63 | 64 | describe '--dry' do 65 | it 'shows but does not save anything' do 66 | allow(GitHubClient).to receive(:new).and_return(github_client) 67 | expect(github_client).not_to receive(:put_secret) 68 | expect(github_client).not_to receive(:delete_secret) 69 | expect { subject.execute %W[bulk save #{config_file} --clean --dry] }.to output_approval('cli/bulk/save-dry') 70 | end 71 | end 72 | 73 | describe '--only REPO' do 74 | it 'saves all variables to one repo only' do 75 | expect { subject.execute %W[bulk save #{config_file} --only user/repo] } 76 | .to output_approval('cli/bulk/save-only') 77 | end 78 | end 79 | end 80 | 81 | describe 'clean' do 82 | before { reset_tmp_dir } 83 | 84 | it 'deletes keys that are not configured' do 85 | expect { subject.execute %W[bulk clean #{config_file}] }.to output_approval('cli/bulk/clean') 86 | end 87 | 88 | describe '--dry' do 89 | it 'shows but does not clean anything' do 90 | allow(GitHubClient).to receive(:new).and_return(github_client) 91 | expect(github_client).not_to receive(:delete_secret) 92 | expect { subject.execute %W[bulk clean #{config_file} --dry] }.to output_approval('cli/bulk/clean-dry') 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Change Log 2 | ======================================== 3 | 4 | v0.2.2 - 2024-02-10 5 | ---------------------------------------- 6 | 7 | - Fix Psych 4 errors for Ruby 3.1 8 | - Refactor with rubocop 9 | - Drop support for Ruby 2.5 10 | - Drop support for Ruby 2.6 11 | - Drop support for Ruby 2.x 12 | - Updates for Ruby 3.3 and Rack 3 13 | 14 | 15 | 16 | ## [v0.2.1](https://github.com/DannyBen/secret_hub/tree/v0.2.1) (2020-05-18) 17 | 18 | [Full Changelog](https://github.com/DannyBen/secret_hub/compare/v0.2.0...v0.2.1) 19 | 20 | **Merged pull requests:** 21 | 22 | - Add more support for secrets from env vars [\#9](https://github.com/DannyBen/secret_hub/pull/9) ([DannyBen](https://github.com/DannyBen)) 23 | 24 | ## [v0.2.0](https://github.com/DannyBen/secret_hub/tree/v0.2.0) (2020-05-15) 25 | 26 | [Full Changelog](https://github.com/DannyBen/secret_hub/compare/v0.1.6...v0.2.0) 27 | 28 | **Merged pull requests:** 29 | 30 | - Add support for organization secrets [\#8](https://github.com/DannyBen/secret_hub/pull/8) ([DannyBen](https://github.com/DannyBen)) 31 | 32 | ## [v0.1.6](https://github.com/DannyBen/secret_hub/tree/v0.1.6) (2020-04-17) 33 | 34 | [Full Changelog](https://github.com/DannyBen/secret_hub/compare/v0.1.5...v0.1.6) 35 | 36 | **Merged pull requests:** 37 | 38 | - Fix exception handling on missing bulk config file [\#7](https://github.com/DannyBen/secret_hub/pull/7) ([DannyBen](https://github.com/DannyBen)) 39 | 40 | ## [v0.1.5](https://github.com/DannyBen/secret_hub/tree/v0.1.5) (2020-02-17) 41 | 42 | [Full Changelog](https://github.com/DannyBen/secret_hub/compare/v0.1.4...v0.1.5) 43 | 44 | **Merged pull requests:** 45 | 46 | - Add bulk save --only repo [\#6](https://github.com/DannyBen/secret_hub/pull/6) ([DannyBen](https://github.com/DannyBen)) 47 | 48 | ## [v0.1.4](https://github.com/DannyBen/secret_hub/tree/v0.1.4) (2020-02-15) 49 | 50 | [Full Changelog](https://github.com/DannyBen/secret_hub/compare/v0.1.3...v0.1.4) 51 | 52 | **Merged pull requests:** 53 | 54 | - Add dry run for save and clean operations [\#5](https://github.com/DannyBen/secret_hub/pull/5) ([DannyBen](https://github.com/DannyBen)) 55 | 56 | ## [v0.1.3](https://github.com/DannyBen/secret_hub/tree/v0.1.3) (2020-02-15) 57 | 58 | [Full Changelog](https://github.com/DannyBen/secret_hub/compare/v0.1.2...v0.1.3) 59 | 60 | **Merged pull requests:** 61 | 62 | - Tolerate missing secrets [\#4](https://github.com/DannyBen/secret_hub/pull/4) ([DannyBen](https://github.com/DannyBen)) 63 | 64 | ## [v0.1.2](https://github.com/DannyBen/secret_hub/tree/v0.1.2) (2020-02-15) 65 | 66 | [Full Changelog](https://github.com/DannyBen/secret_hub/compare/v0.1.1...v0.1.2) 67 | 68 | **Merged pull requests:** 69 | 70 | - More flexible config [\#3](https://github.com/DannyBen/secret_hub/pull/3) ([DannyBen](https://github.com/DannyBen)) 71 | 72 | ## [v0.1.1](https://github.com/DannyBen/secret_hub/tree/v0.1.1) (2020-02-15) 73 | 74 | [Full Changelog](https://github.com/DannyBen/secret_hub/compare/v0.1.0...v0.1.1) 75 | 76 | **Merged pull requests:** 77 | 78 | - Output list in YAML compatible format [\#2](https://github.com/DannyBen/secret_hub/pull/2) ([DannyBen](https://github.com/DannyBen)) 79 | 80 | ## [v0.1.0](https://github.com/DannyBen/secret_hub/tree/v0.1.0) (2020-02-14) 81 | 82 | [Full Changelog](https://github.com/DannyBen/secret_hub/compare/ab04d312349ca7815efdbc5794c576644314e49d...v0.1.0) 83 | 84 | **Merged pull requests:** 85 | 86 | - Add badges and github workflow [\#1](https://github.com/DannyBen/secret_hub/pull/1) ([DannyBen](https://github.com/DannyBen)) 87 | -------------------------------------------------------------------------------- /lib/secret_hub/github_client.rb: -------------------------------------------------------------------------------- 1 | require 'httparty' 2 | require 'secret_hub/sodium' 3 | 4 | module SecretHub 5 | class GitHubClient 6 | include Sodium 7 | include HTTParty 8 | 9 | def initialize 10 | self.class.base_uri ENV['SECRET_HUB_API_BASE'] || 'https://api.github.com' 11 | end 12 | 13 | # GET /repos/:owner/:repo/actions/secrets/public-key 14 | # GET /orgs/:org/actions/secrets/public-key 15 | def public_key(repo_or_org) 16 | if repo_or_org.include? '/' 17 | repo = repo_or_org 18 | public_keys[repo_or_org] ||= get("/repos/#{repo}/actions/secrets/public-key") 19 | else 20 | org = repo_or_org 21 | public_keys[repo_or_org] ||= get("/orgs/#{org}/actions/secrets/public-key") 22 | end 23 | end 24 | 25 | # GET /repos/:owner/:repo/actions/secrets 26 | def secrets(repo) 27 | response = get "/repos/#{repo}/actions/secrets" 28 | response['secrets'].map { |s| s['name'] } 29 | end 30 | 31 | # GET /orgs/:org/actions/secrets 32 | def org_secrets(org) 33 | response = get "/orgs/#{org}/actions/secrets" 34 | response['secrets'].map { |s| s['name'] } 35 | end 36 | 37 | # PUT /repos/:owner/:repo/actions/secrets/:name 38 | def put_secret(repo, name, value) 39 | secret = encrypt_for repo, value 40 | key_id = public_key(repo)['key_id'] 41 | put "/repos/#{repo}/actions/secrets/#{name}", 42 | encrypted_value: secret, 43 | key_id: key_id 44 | end 45 | 46 | # PUT /orgs/:org/actions/secrets/:secret_name 47 | def put_org_secret(org, name, value) 48 | secret = encrypt_for org, value 49 | key_id = public_key(org)['key_id'] 50 | put "/orgs/#{org}/actions/secrets/#{name}", 51 | encrypted_value: secret, 52 | key_id: key_id, 53 | visibility: 'private' 54 | end 55 | 56 | # DELETE /repos/:owner/:repo/actions/secrets/:name 57 | def delete_secret(repo, name) 58 | delete "/repos/#{repo}/actions/secrets/#{name}" 59 | end 60 | 61 | # DELETE /orgs/:org/actions/secrets/:secret_name 62 | def delete_org_secret(org, name) 63 | delete "/orgs/#{org}/actions/secrets/#{name}" 64 | end 65 | 66 | private 67 | 68 | def public_keys 69 | @public_keys ||= {} 70 | end 71 | 72 | def encrypt_for(repo_or_org, secret) 73 | public_key = public_key(repo_or_org)['key'] 74 | encrypt secret, public_key 75 | end 76 | 77 | def get(url) 78 | response = self.class.get(url, request_options) 79 | response.success? or raise APIError, response 80 | response.parsed_response 81 | end 82 | 83 | def put(url, args = {}) 84 | options = { body: args.to_json } 85 | all_options = request_options.merge options 86 | response = self.class.put url, all_options 87 | response.success? or raise APIError, response 88 | end 89 | 90 | def delete(url) 91 | response = self.class.delete url, request_options 92 | response.success? or raise APIError, response 93 | end 94 | 95 | def request_options 96 | { headers: headers } 97 | end 98 | 99 | def headers 100 | { 101 | 'Authorization' => "token #{secret_token}", 102 | 'User-Agent' => 'SecretHub Gem', 103 | } 104 | end 105 | 106 | def secret_token 107 | ENV['GITHUB_ACCESS_TOKEN'] || raise(ConfigurationError, 'Please set GITHUB_ACCESS_TOKEN') 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SecretHub - GitHub Secrets CLI 2 | 3 | SecretHub lets you easily manage your GitHub secrets from the command line 4 | with support for bulk operations and organization secrets. 5 | 6 | --- 7 | 8 | ## Installation 9 | 10 | With Ruby: 11 | 12 | ```shell 13 | $ gem install secret_hub 14 | ``` 15 | 16 | Or with Docker: 17 | 18 | ```shell 19 | $ alias secrethub='docker run --rm -it -e GITHUB_ACCESS_TOKEN -v "$PWD:/app" dannyben/secrethub' 20 | ``` 21 | 22 | ## Prerequisites 23 | 24 | SecretHub is a wrapper around the [GitHub Secrets API][secrets-api]. To use 25 | it, you need to set up your environment with a 26 | [GitHub Access Token][access-key]: 27 | 28 | 29 | ```shell 30 | $ export GITHUB_ACCESS_TOKEN= 31 | ``` 32 | 33 | Give your token the `repo` scope, and for organization secrets, the `admin:org` scope. 34 | 35 | ## Usage 36 | 37 | SecretHub has three families of commands: 38 | 39 | 1. `secrethub repo` - manage repository secrets. 40 | 2. `secrethub org` - manage organization secrets. 41 | 3. `secrethub bulk` - manage multiple secrets in multiple repositories using a config file. 42 | 43 | ```shell 44 | $ secrethub 45 | GitHub Secret Manager 46 | 47 | Commands: 48 | repo Manage repository secrets 49 | org Manage organization secrets 50 | bulk Manage multiple secrets in multiple repositories 51 | 52 | Run secrethub COMMAND --help for command specific help 53 | 54 | 55 | $ secrethub repo 56 | Usage: 57 | secrethub repo list REPO 58 | secrethub repo save REPO KEY [VALUE] 59 | secrethub repo delete REPO KEY 60 | secrethub repo (-h|--help) 61 | 62 | 63 | $ secrethub org 64 | Usage: 65 | secrethub org list ORG 66 | secrethub org save ORG KEY [VALUE] 67 | secrethub org delete ORG KEY 68 | secrethub org (-h|--help) 69 | 70 | 71 | $ secrethub bulk 72 | Usage: 73 | secrethub bulk init [CONFIG] 74 | secrethub bulk show [CONFIG --visible] 75 | secrethub bulk list [CONFIG] 76 | secrethub bulk save [CONFIG --clean --dry --only REPO] 77 | secrethub bulk clean [CONFIG --dry] 78 | secrethub bulk (-h|--help) 79 | ``` 80 | 81 | ## Bulk operations 82 | 83 | All the bulk operations use a simple YAML configuration file. 84 | The configuration file includes a list of GitHub repositories, each with a 85 | list of its secrets. 86 | 87 | For example: 88 | 89 | ```yaml 90 | # secrethub.yml 91 | user/repo: 92 | - SECRET 93 | - PASSWORD 94 | - SECRET_KEY 95 | 96 | user/another-repo: 97 | - SECRET 98 | - SECRET_KEY 99 | ``` 100 | 101 | Each list of secrets can either be an array, or a hash. 102 | 103 | ### Using array syntax 104 | 105 | All secrets must be defined as environment variables. 106 | 107 | ```yaml 108 | user/repo: 109 | - SECRET 110 | - PASSWORD 111 | ``` 112 | 113 | ### Using hash syntax 114 | 115 | Each secret may define its value, or leave it blank. When a secret value is 116 | blank, it will be loaded from the environment. 117 | 118 | ```yaml 119 | user/another-repo: 120 | SECRET: 121 | PASSWORD: p4ssw0rd 122 | ``` 123 | 124 | ### Using YAML anchors 125 | 126 | SecretHub ignores any key that does not look like a repository (does not 127 | include a slash `/`). Using this feature, you can define reusable YAML 128 | anchors: 129 | 130 | ```yaml 131 | docker: &docker 132 | DOCKER_USER: 133 | DOCKER_PASSWORD: 134 | 135 | user/repo: 136 | <<: *docker 137 | SECRET: 138 | PASSWORD: p4ssw0rd 139 | ``` 140 | 141 | Note that YAML anchors only work with the hash syntax. 142 | 143 | 144 | ## Contributing / Support 145 | 146 | If you experience any issue, have a question or a suggestion, or if you wish 147 | to contribute, feel free to [open an issue][issues]. 148 | 149 | --- 150 | 151 | [secrets-api]: https://developer.github.com/v3/actions/secrets/ 152 | [access-key]: https://github.com/settings/tokens 153 | [issues]: https://github.com/DannyBen/secret_hub/issues 154 | -------------------------------------------------------------------------------- /lib/secret_hub/commands/bulk.rb: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | require 'secret_hub/refinements/string_obfuscation' 3 | 4 | module SecretHub 5 | module Commands 6 | class Bulk < Base 7 | using StringObfuscation 8 | 9 | summary 'Manage multiple secrets in multiple repositories' 10 | 11 | usage 'secrethub bulk init [CONFIG]' 12 | usage 'secrethub bulk show [CONFIG --visible]' 13 | usage 'secrethub bulk list [CONFIG]' 14 | usage 'secrethub bulk save [CONFIG --clean --dry --only REPO]' 15 | usage 'secrethub bulk clean [CONFIG --dry]' 16 | usage 'secrethub bulk (-h|--help)' 17 | 18 | command 'init', 'Create a sample configuration file in the current directory' 19 | command 'show', 'Show the configuration file' 20 | command 'save', 'Save multiple secrets to multiple repositories' 21 | command 'clean', 'Delete secrets from multiple repositories unless they are specified in the config file' 22 | command 'list', 'Show all secrets in all repositories' 23 | 24 | option '-c, --clean', 'Also delete any other secret not defined in the configuration file' 25 | option '-v, --visible', 'Also show secret values' 26 | option '-d, --dry', 'Dry run' 27 | option '-o, --only REPO', 'Save all secrets to a single repository from the configuration file' 28 | 29 | param 'CONFIG', 'Path to the configuration file [default: secrethub.yml]' 30 | 31 | example 'secrethub bulk init' 32 | example 'secrethub bulk show --visible' 33 | example 'secrethub bulk clean' 34 | example 'secrethub bulk list mysecrets.yml' 35 | example 'secrethub bulk save mysecrets.yml --dry' 36 | example 'secrethub bulk save --clean' 37 | example 'secrethub bulk save --only me/my-important-repo' 38 | 39 | def init_command 40 | raise SecretHubError, "File #{config_file} already exists" if File.exist? config_file 41 | 42 | FileUtils.cp config_template, config_file 43 | say "Saved g`#{config_file}`" 44 | end 45 | 46 | def show_command 47 | config.each do |repo, secrets| 48 | say "b`#{repo}`:" 49 | secrets.each do |key, value| 50 | show_secret key, value, args['--visible'] 51 | end 52 | end 53 | end 54 | 55 | def list_command 56 | config.each_repo do |repo| 57 | say "b`#{repo}`:" 58 | github.secrets(repo).each do |secret| 59 | say "- m`#{secret}`" 60 | end 61 | end 62 | end 63 | 64 | def save_command 65 | dry = args['--dry'] 66 | only = args['--only'] 67 | skipped = 0 68 | 69 | config.each do |repo, secrets| 70 | next if only && (repo != only) 71 | 72 | say "b`#{repo}`" 73 | skipped += update_repo repo, secrets, dry 74 | clean_repo repo, secrets.keys, dry if args['--clean'] 75 | end 76 | 77 | puts "\n" if skipped.positive? || dry 78 | say "Skipped #{skipped} missing secrets" if skipped.positive? 79 | say 'Dry run, nothing happened' if dry 80 | end 81 | 82 | def clean_command 83 | dry = args['--dry'] 84 | 85 | config.each do |repo, secrets| 86 | say "b`#{repo}`" 87 | clean_repo repo, secrets.keys, dry 88 | end 89 | 90 | say "\nDry run, nothing happened" if dry 91 | end 92 | 93 | private 94 | 95 | def clean_repo(repo, keys, dry) 96 | repo_keys = github.secrets repo 97 | delete_candidates = repo_keys - keys 98 | 99 | delete_candidates.each do |key| 100 | say "delete m`#{key}` " 101 | github.delete_secret repo, key unless dry 102 | say 'g`OK`' 103 | end 104 | end 105 | 106 | def update_repo(repo, secrets, dry) 107 | skipped = 0 108 | 109 | secrets.each do |key, value| 110 | say "save m`#{key}` " 111 | if value 112 | github.put_secret repo, key, value unless dry 113 | say 'g`OK`' 114 | else 115 | say 'r`MISSING`' 116 | skipped += 1 117 | end 118 | end 119 | 120 | skipped 121 | end 122 | 123 | def show_secret(key, value, visible) 124 | if value 125 | value = value.obfuscate unless visible 126 | say " m`#{key}`: c`#{value}`" 127 | else 128 | say " m`#{key}`: r`*MISSING*`" 129 | end 130 | end 131 | 132 | def config_file 133 | args['CONFIG'] || 'secrethub.yml' 134 | end 135 | 136 | def config 137 | @config ||= Config.load config_file 138 | end 139 | 140 | def config_template 141 | File.expand_path '../config-template.yml', __dir__ 142 | end 143 | end 144 | end 145 | end 146 | --------------------------------------------------------------------------------