├── requirements.pip ├── permission.png ├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── Dockerfile ├── action.yml ├── entrypoint.sh ├── LICENSE ├── README.md ├── test_run.py ├── .gitignore └── run.py /requirements.pip: -------------------------------------------------------------------------------- 1 | requests==2.32.3 2 | PyNaCl==1.5.0 -------------------------------------------------------------------------------- /permission.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rennf93/github-actions-secrets-mgmt/HEAD/permission.png -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | 13 | - package-ecosystem: "docker" 14 | directory: "/" 15 | schedule: 16 | interval: "weekly" -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13-alpine3.20 2 | 3 | WORKDIR /usr/src/app 4 | 5 | ENV PYTHONDONTWRITEBYTECODE=1 6 | ENV PYTHONUNBUFFERED=1 7 | 8 | # Install system dependencies 9 | RUN apk update && apk add --no-cache \ 10 | gcc \ 11 | musl-dev \ 12 | libffi-dev \ 13 | openssl-dev \ 14 | make 15 | 16 | COPY ./requirements.pip ./requirements.pip 17 | RUN pip install --upgrade pip && \ 18 | pip install -r requirements.pip 19 | 20 | COPY ./run.py ./run.py 21 | COPY ./entrypoint.sh /entrypoint.sh 22 | 23 | RUN chmod +x /entrypoint.sh 24 | 25 | ENTRYPOINT ["/entrypoint.sh"] -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'GitHub Actions Secrets Management' 2 | description: 'A GitHub Action to manage GitHub Actions secrets programmatically.' 3 | author: 'Renzo Franceschini' 4 | 5 | inputs: 6 | OWNER: 7 | description: 'The owner of the repository' 8 | required: true 9 | REPOSITORY: 10 | description: 'The name of the repository' 11 | required: true 12 | ACCESS_TOKEN: 13 | description: 'The personal access token for authentication' 14 | required: true 15 | SECRET_NAME: 16 | description: 'The name of the secret to be created or updated' 17 | required: true 18 | SECRET_VALUE: 19 | description: 'The value of the secret to be created or updated' 20 | required: true 21 | 22 | runs: 23 | using: 'docker' 24 | image: 'Dockerfile' 25 | 26 | branding: 27 | icon: 'settings' 28 | color: 'blue' -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -l 2 | 3 | # Extract inputs from 'with' GitHub context using the INPUT_ prefix 4 | export OWNER="${INPUT_OWNER}" 5 | export REPOSITORY="${INPUT_REPOSITORY}" 6 | export ACCESS_TOKEN="${INPUT_ACCESS_TOKEN}" 7 | export SECRET_NAME="${INPUT_SECRET_NAME}" 8 | export SECRET_VALUE="${INPUT_SECRET_VALUE}" 9 | 10 | # Check if required inputs are provided 11 | if [ -z "$OWNER" ]; then 12 | echo "OWNER is a required input and must be set." 13 | exit 1 14 | fi 15 | 16 | if [ -z "$REPOSITORY" ]; then 17 | echo "REPOSITORY is a required input and must be set." 18 | exit 1 19 | fi 20 | 21 | if [ -z "$ACCESS_TOKEN" ]; then 22 | echo "ACCESS_TOKEN is a required input and must be set." 23 | exit 1 24 | fi 25 | 26 | if [ -z "$SECRET_NAME" ]; then 27 | echo "SECRET_NAME is a required input and must be set." 28 | exit 1 29 | fi 30 | 31 | if [ -z "$SECRET_VALUE" ]; then 32 | echo "SECRET_VALUE is a required input and must be set." 33 | exit 1 34 | fi 35 | 36 | # Run the Python script with the provided inputs 37 | python /usr/src/app/run.py -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Renzo F 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request_target: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | 15 | permissions: 16 | pull-requests: write 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v6 21 | 22 | - name: Set up Python 23 | uses: actions/setup-python@v6 24 | with: 25 | python-version: '3.x' 26 | 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install -r requirements.pip 31 | 32 | - name: Check Docker Hub Status 33 | uses: crazy-max/ghaction-docker-status@v3 34 | with: 35 | overall_threshold: degraded_performance 36 | authentication_threshold: service_disruption 37 | hub_registry_threshold: service_disruption 38 | 39 | - name: Log in to DockerHub 40 | uses: docker/login-action@v3.6.0 41 | with: 42 | username: ${{ secrets.DOCKER_USERNAME }} 43 | password: ${{ secrets.DOCKER_PASSWORD }} 44 | ecr: auto 45 | logout: true 46 | 47 | - name: Verify Docker Login 48 | run: docker info 49 | 50 | - name: Build and Push Docker image 51 | uses: docker/build-push-action@v6 52 | with: 53 | context: . 54 | file: ./Dockerfile 55 | push: true 56 | tags: ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE }}:latest 57 | secrets: | 58 | DOCKER_IMAGE=${{ secrets.DOCKER_IMAGE }} 59 | 60 | - name: Pull Docker Image 61 | run: docker pull ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE }}:latest 62 | 63 | - name: Docker Scout 64 | id: docker-scout 65 | uses: docker/scout-action@v1.18.2 66 | with: 67 | command: cves,recommendations,compare 68 | image: ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE }}:latest 69 | to-latest: true 70 | ignore-base: true 71 | ignore-unchanged: true 72 | only-fixed: true 73 | organization: ${{ secrets.DOCKER_USERNAME }} 74 | summary: true 75 | format: json 76 | github-token: ${{ secrets.GITHUB_TOKEN }} 77 | write-comment: true 78 | 79 | - name: Run tests 80 | run: python -m unittest discover -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # github-actions-secrets-mgmt 2 | 3 | This Actions project provides a tool to manage GitHub Actions secrets programmatically. 4 | 5 | --- 6 | ## Features 7 | - Retrieve environment variables 8 | - Generate authentication headers 9 | - Retrieve public key details from GitHub 10 | - Encrypt secrets using NaCl 11 | - Save secrets to GitHub Actions 12 | 13 | --- 14 | ## Requirements 15 | - Python 3.11+ 16 | - `requests` library 17 | - `PyNaCl` library 18 | 19 | --- 20 | ## Usage 21 | 22 | ```yaml 23 | - name: Create or update Github Actions secret 24 | uses: rennf93/github-actions-secrets-mgmt@v1.0 25 | with: 26 | OWNER: 27 | REPOSITORY: 28 | ACCESS_TOKEN: 29 | SECRET_NAME: 30 | SECRET_VALUE: 31 | ``` 32 | 33 | where 34 | 35 | `OWNER` is the owner of the repository where the secret is to be created or updated. Required. 36 | 37 | `REPOSITORY` is the name of the respository where the secret is to be created or updated. Required. 38 | 39 | `ACCESS_TOKEN` is the personal access token (PAT) to use for authentication against the repository where the secret is stored. Using `secrets.GIHUB_TOKEN` [will not work](https://github.com/orgs/community/discussions/12424). Follow steps [here](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) to create one if you dont already have one. Besure to allow the token to be used to read user public keys. 40 | 41 | Required: 42 | ![permission](permission.png) 43 | 44 | 45 | `SECRET_NAME` is the name of the secret to be created or updated. Required. 46 | 47 | `SECRET_VALUE` is value the secret should be set to. Optional. This should be an output from a previous step or job. For reference: [here](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idoutputs) 48 | 49 | 50 | To view the newly created secret, navigate to settings >> secrets >> actions in the Github repository portal. 51 | 52 | --- 53 | ## References 54 | 55 | 1. [Create personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) 56 | 1. [Get repository public key](https://docs.github.com/en/rest/actions/secrets#get-a-repository-public-key) 57 | 1. [Create or update a repository secret 58 | ](https://docs.github.com/en/rest/actions/secrets#create-or-update-a-repository-secret) 59 | 1. [Custom actions](https://docs.github.com/en/actions/creating-actions/about-custom-actions) 60 | 61 | 62 | ![Custom Badge](https://rennf93.github.io/project-assets/images/rf-icon.png) 63 | -------------------------------------------------------------------------------- /test_run.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, MagicMock 3 | import run 4 | import requests 5 | 6 | class TestRun(unittest.TestCase): 7 | 8 | @patch('run.os.getenv') 9 | def test_retrieve_input(self, mock_getenv): 10 | mock_getenv.return_value = 'test_value' 11 | self.assertEqual(run.retrieve_input('TEST_ENV'), 'test_value') 12 | mock_getenv.return_value = None 13 | with self.assertRaises(SystemExit): 14 | run.retrieve_input('TEST_ENV') 15 | 16 | def test_generate_authentication_headers(self): 17 | headers = run.generate_authentication_headers('test_token') 18 | expected_headers = { 19 | "Accept": "application/vnd.github+json", 20 | "Authorization": "Bearer test_token", 21 | } 22 | self.assertEqual(headers, expected_headers) 23 | 24 | @patch('run.requests.get') 25 | def test_retrieve_public_key_details(self, mock_get): 26 | mock_response = MagicMock() 27 | mock_response.status_code = 200 28 | mock_response.json.return_value = {'key': 'test_key'} 29 | mock_get.return_value = mock_response 30 | self.assertEqual(run.retrieve_public_key_details('http://test', 'token'), {'key': 'test_key'}) 31 | 32 | mock_response.status_code = 404 33 | mock_get.return_value = mock_response 34 | mock_get.side_effect = requests.RequestException("Error") 35 | with self.assertRaises(SystemExit): 36 | run.retrieve_public_key_details('http://test', 'token') 37 | 38 | @patch('run.public.PublicKey') 39 | @patch('run.public.SealedBox') 40 | def test_encrypt_secret(self, mock_sealed_box, mock_public_key): 41 | mock_public_key.return_value = MagicMock() 42 | mock_sealed_box_instance = MagicMock() 43 | mock_sealed_box.return_value = mock_sealed_box_instance 44 | mock_sealed_box_instance.encrypt.return_value = b'encrypted_value' 45 | encrypted = run.encrypt_secret('test_key', 'utf-8', 'secret') 46 | self.assertEqual(encrypted, 'ZW5jcnlwdGVkX3ZhbHVl') 47 | 48 | mock_public_key.side_effect = Exception('Encryption error') 49 | with self.assertRaises(SystemExit): 50 | run.encrypt_secret('test_key', 'utf-8', 'secret') 51 | 52 | @patch('run.requests.put') 53 | def test_save_secret(self, mock_put): 54 | mock_response = MagicMock() 55 | mock_response.status_code = 201 56 | mock_put.return_value = mock_response 57 | run.save_secret('http://test', 'token', 'key_id', 'secret_name', 'secret') 58 | 59 | mock_response.status_code = 400 60 | mock_put.return_value = mock_response 61 | mock_put.side_effect = requests.RequestException("Error") 62 | with self.assertRaises(SystemExit): 63 | run.save_secret('http://test', 'token', 'key_id', 'secret_name', 'secret') 64 | 65 | 66 | 67 | if __name__ == '__main__': 68 | unittest.main() -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | .pybuilder/ 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | # For a library or package, you might want to ignore these files since the code is 86 | # intended to run in multiple environments; otherwise, check them in: 87 | # .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # poetry 97 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 98 | # This is especially recommended for binary packages to ensure reproducibility, and is more 99 | # commonly ignored for libraries. 100 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 101 | #poetry.lock 102 | 103 | # pdm 104 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 105 | #pdm.lock 106 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 107 | # in version control. 108 | # https://pdm.fming.dev/#use-with-ide 109 | .pdm.toml 110 | 111 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 112 | __pypackages__/ 113 | 114 | # Celery stuff 115 | celerybeat-schedule 116 | celerybeat.pid 117 | 118 | # SageMath parsed files 119 | *.sage.py 120 | 121 | # Environments 122 | .env 123 | .venv 124 | env/ 125 | venv/ 126 | ENV/ 127 | env.bak/ 128 | venv.bak/ 129 | 130 | # Spyder project settings 131 | .spyderproject 132 | .spyproject 133 | 134 | # Rope project settings 135 | .ropeproject 136 | 137 | # mkdocs documentation 138 | /site 139 | 140 | # mypy 141 | .mypy_cache/ 142 | .dmypy.json 143 | dmypy.json 144 | 145 | # Pyre type checker 146 | .pyre/ 147 | 148 | # pytype static type analyzer 149 | .pytype/ 150 | 151 | # Cython debug symbols 152 | cython_debug/ 153 | 154 | # PyCharm 155 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 156 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 157 | # and can be added to the global gitignore or merged into this file. For a more nuclear 158 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 159 | #.idea/ 160 | 161 | # OTHER // CUSTOM 162 | .DS_Store 163 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode 2 | import json 3 | from nacl import encoding, public 4 | import requests 5 | import sys 6 | import os 7 | import logging 8 | from typing import Dict, Any 9 | 10 | 11 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') 12 | 13 | 14 | 15 | def retrieve_input(env: str) -> str: 16 | """ 17 | Retrieve an input from the environment. 18 | """ 19 | value = os.getenv(env) 20 | if value is None: 21 | logging.error(f"{env} is a required input and must be set.") 22 | sys.exit(1) 23 | return value 24 | 25 | 26 | 27 | def generate_authentication_headers(access_token: str) -> Dict[str, str]: 28 | """ 29 | Generate authentication headers for GitHub API. 30 | """ 31 | return { 32 | "Accept": "application/vnd.github+json", 33 | "Authorization": f"Bearer {access_token}", 34 | } 35 | 36 | 37 | 38 | def retrieve_public_key_details( 39 | base_url: str, 40 | access_token: str 41 | ) -> Dict[str, Any]: 42 | """ 43 | Retrieve public key details from GitHub Actions Secrets. 44 | """ 45 | try: 46 | response = requests.get( 47 | f"{base_url}/public-key", 48 | headers=generate_authentication_headers(access_token) 49 | ) 50 | response.raise_for_status() 51 | except requests.RequestException as e: 52 | logging.error(f"Failed to retrieve public key: {e}") 53 | sys.exit(1) 54 | return response.json() 55 | 56 | 57 | 58 | def encrypt_secret( 59 | key: str, 60 | coding: str, 61 | secret_plain: str 62 | ) -> str: 63 | """ 64 | Encrypt a secret using the public key. 65 | """ 66 | try: 67 | public_key = public.PublicKey(key.encode(coding), encoding.Base64Encoder()) 68 | sealed_box = public.SealedBox(public_key) 69 | encrypted = sealed_box.encrypt(secret_plain.encode(coding)) 70 | return b64encode(encrypted).decode(coding) 71 | except Exception as e: 72 | logging.error(f"Failed to encrypt secret: {e}") 73 | sys.exit(1) 74 | 75 | 76 | 77 | def save_secret( 78 | base_url: str, 79 | access_token: str, 80 | key_id: str, 81 | secret_name: str, 82 | secret: str 83 | ) -> None: 84 | """ 85 | Save a secret to GitHub Actions Secrets. 86 | """ 87 | try: 88 | response = requests.put( 89 | f"{base_url}/{secret_name}", 90 | headers=generate_authentication_headers(access_token), 91 | data=json.dumps({ 92 | "encrypted_value": secret, 93 | "key_id": key_id, 94 | }) 95 | ) 96 | logging.info(f"Save secret response status code: {response.status_code}") 97 | response.raise_for_status() 98 | except requests.RequestException as e: 99 | logging.error(f"Error saving secret: {e}") 100 | sys.exit(1) 101 | 102 | 103 | 104 | if __name__ == "__main__": 105 | logging.info('Extracting input ...') 106 | owner = retrieve_input('OWNER') 107 | repository = retrieve_input('REPOSITORY') 108 | access_token = retrieve_input('ACCESS_TOKEN') 109 | secret_name = retrieve_input('SECRET_NAME') 110 | secret_value = os.getenv('SECRET_VALUE', '') 111 | 112 | base_url = f"https://api.github.com/repos/{owner}/{repository}/actions/secrets" 113 | coding = "utf-8" 114 | 115 | logging.info(f"Retrieving public key for {owner}/{repository} ...") 116 | key = retrieve_public_key_details(base_url, access_token) 117 | 118 | logging.info("Encrypting secret value ...") 119 | secret = encrypt_secret(key['key'], coding, secret_value) 120 | 121 | logging.info(f"Saving secret value in GitHub action secret {secret_name} ...") 122 | save_secret(base_url, access_token, key['key_id'], secret_name, secret) 123 | logging.info("Secret saved successfully!") --------------------------------------------------------------------------------