-
Notifications
You must be signed in to change notification settings - Fork 33
/
github-upload-secrets
executable file
·213 lines (168 loc) · 8.42 KB
/
github-upload-secrets
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
#!/usr/bin/env python3
#
# Upload encrypted secrets to GitHub
# With this the secrets can be used by GitHub actions:
# https://developer.github.com/v3/actions/secrets/#create-or-update-an-organization-secret
#
# Secrets are uploaded to the organization by default:
#
# https://github.com/organizations/cockpit-project/settings/secrets
#
# For testing, you can upload it to a particular project with --repository OWNER/REPO
# https://github.com/OWNER/REPO/settings/secrets
#
# This file is part of Cockpit.
#
# Copyright (C) 2020 Red Hat, Inc.
#
# Cockpit is free software; you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation; either version 2.1 of the License, or
# (at your option) any later version.
#
# Cockpit is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
import argparse
import os
import re
import subprocess
import tempfile
from base64 import b64encode
from collections.abc import Mapping
from nacl import encoding, public
import task
def encrypt(public_key: str, secret_value: bytes) -> str:
"""Encrypt a Unicode string using the public key."""
pubkey = public.PublicKey(public_key.encode("utf-8"), encoding.Base64Encoder)
sealed_box = public.SealedBox(pubkey)
encrypted = sealed_box.encrypt(secret_value)
return b64encode(encrypted).decode("utf-8")
def generate_ssh_key() -> tuple[bytes, bytes]:
"""Generates a Ed25519 keypair and outputs public/private in OpenSSH format"""
# We could also do this, using cryptography.hazmat.primitives:
#
# key = Ed25519PrivateKey.generate()
# public = key.public_key().public_bytes(Encoding.OpenSSH, PublicFormat.OpenSSH)
# private = key.private_bytes(Encoding.PEM, PrivateFormat.OpenSSH, NoEncryption())
#
# but we make a conscious design decision to use ssh-keygen, regardless of
# how awkward it is: ssh-keygen knows how to make good SSH keys. The word
# "hazmat" in the module name is enough of a warning here about the dangers
# of getting this stuff wrong.
#
# See discussion in https://github.com/cockpit-project/bots/pull/2192
with tempfile.TemporaryDirectory() as tmpdir:
key = f'{tmpdir}/link-to-fd3-and-fd4'
os.symlink('/proc/self/fd/3', f'{key}')
os.symlink('/proc/self/fd/4', f'{key}.pub')
cmd = f'ssh-keygen -t ed25519 -C "" -N "" -q -f {key} 3>&1 4>&2 &>/dev/null'
process = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
private, public = process.communicate(b'y\n')
return public, private
def upload_secrets(
api: task.github.GitHub, repo: str, env: str, secrets: Mapping[str, bytes], opts: argparse.Namespace
) -> None:
env_path = f"/repos/{repo}/environments/{env}"
# create env if not present already
if api.get(env_path):
print(f"Environment {env} already exists")
else:
print(f"Creating non-existing environment {env}")
api.put(env_path, {})
secrets_path = f"{env_path}/secrets"
pubkey = api.get(f"{secrets_path}/public-key")
for name, secret in secrets.items():
if opts.dry_run or opts.verbose:
# the path and names of the secrets are not secret
print("Would upload" if opts.dry_run else "Uploading", name, "to", secrets_path)
if not opts.dry_run:
encrypted = encrypt(pubkey["key"], secret)
payload = {"key_id": pubkey["key_id"], "encrypted_value": encrypted, "visibility": "all"}
api.put(f"{secrets_path}/{name}", payload)
def upload_deploy_key(
api: task.github.GitHub, repo: str, title: str, content: bytes, opts: argparse.Namespace
) -> None:
keys_path = f"/repos/{repo.lstrip('+')}/keys"
if repo.startswith('+'):
# add this key to the existing keys
pass
else:
# delete all existing keys
keys = api.get(keys_path)
for key in (keys or []):
key_id = key['id']
key_path = f'{keys_path}/{key_id}'
if opts.dry_run or opts.verbose:
print("Would delete" if opts.dry_run else "Deleting", key_path)
if not opts.dry_run:
api.delete(key_path)
# upload new key
if opts.dry_run or opts.verbose:
print(opts.dry_run and "Would upload pubkey to" if opts.dry_run else "Uploading pubkey to", keys_path)
if not opts.dry_run:
api.post(keys_path, {'title': title, 'key': content.decode('ascii')})
def do_directory(api: task.github.GitHub, repo: str, env: str, directory: str, opts: argparse.Namespace) -> None:
secrets = {}
for entry in os.scandir(directory):
with open(entry.path, 'rb') as f:
secrets[entry.name] = f.read().strip()
upload_secrets(api, repo, env, secrets, opts)
def do_deploy_key(api: task.github.GitHub, deploy_to: str, deploy_from: str, opts: argparse.Namespace) -> None:
repo, env, secret = deploy_from.rsplit('/', 2)
public, private = generate_ssh_key()
upload_deploy_key(api, deploy_to, deploy_from, public, opts)
upload_secrets(api, repo, env, {secret: private}, opts)
def main() -> None:
api = task.github.GitHub()
parser = argparse.ArgumentParser(description='Upload encrypted action secrets to GitHub')
parser.add_argument('--repository', '-r', '--receiver', metavar="OWNER/REPO",
help="The repository which will receive the secrets; default: repo of current checkout")
parser.add_argument('-e', '--env', metavar="ENVNAME",
help="Upload secrets to given project environment")
parser.add_argument('-n', '--dry-run', action="store_true", default=False,
help="Only show which secrets would get uploaded where")
parser.add_argument('-v', '--verbose', action="store_true", default=False,
help="Print verbose information")
megr = parser.add_mutually_exclusive_group(required=True)
megr.add_argument('--directory',
help="Upload given directory with one file per secret")
megr.add_argument('--deploy-to', metavar="[+]OWNER/REPO",
help="with --ssh-keygen, upload public key as deploy key")
megr = parser.add_mutually_exclusive_group()
megr.add_argument('--deploy-from', nargs='+', metavar='OWNER/REPO/ENV/SECRET', action="extend",
help="with --deploy-to, upload private key as named secret")
megr.add_argument('--ssh-keygen', metavar="SECRET_NAME",
help="with --deploy-to, upload private as secret")
opts = parser.parse_args()
NAME_RE = r'[A-Za-z][-0-9A-Za-z_.]*'
REPO_RE = fr'\+?{NAME_RE}/{NAME_RE}'
DEPLOY_FROM_RE = f'{NAME_RE}/{NAME_RE}/{NAME_RE}/{NAME_RE}'
if (opts.deploy_to is None) != (opts.ssh_keygen is None and opts.deploy_from is None):
parser.error('--deploy-to and --deploy-from/--ssh-keygen must be given together')
if not opts.deploy_from and not (opts.repository and opts.env):
parser.error('--repository and --env are required, unless --deploy-from is given')
if opts.deploy_from and (opts.repository or opts.env):
parser.error('--repository and --env cannot be given with --deploy-from')
if opts.repository and not re.fullmatch(REPO_RE, opts.repository, re.I):
parser.error('--repository specifies an invalid org or repository name')
if opts.deploy_to and not re.fullmatch(REPO_RE, opts.deploy_to, re.I):
parser.error('--deploy-to specifies an invalid repository name')
if opts.deploy_from and not all(re.fullmatch(DEPLOY_FROM_RE, df) for df in opts.deploy_from):
parser.error('--deploy-from specifies an invalid OWNER/REPO/ENV/SECRET quad')
if opts.directory:
do_directory(api, opts.repository, opts.env, opts.directory, opts)
elif opts.ssh_keygen:
do_deploy_key(api, opts.deploy_to, f'{opts.repository}/{opts.env}/{opts.ssh_keygen}', opts)
elif opts.deploy_from:
plus = ''
for quad in opts.deploy_from:
do_deploy_key(api, plus + opts.deploy_to, quad, opts)
plus = '+'
if __name__ == '__main__':
main()