diff --git a/.github/workflows/linter.yaml b/.github/workflows/linter.yaml index aff8219..904da2f 100644 --- a/.github/workflows/linter.yaml +++ b/.github/workflows/linter.yaml @@ -12,13 +12,45 @@ on: permissions: contents: read - statuses: write jobs: - lint: + python: + name: Python + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: + - ubuntu-20.04 + - ubuntu-22.04 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Setup Python virtualenv + run: | + python3 -m venv .venv + .venv/bin/pip install --upgrade pip setuptools + .venv/bin/pip install ruff mypy types-requests + + - name: Check ruff formating + run: .venv/bin/ruff format --diff vault_oidc_ssh_cert_action.py + + - name: Check ruff linting + run: .venv/bin/ruff check vault_oidc_ssh_cert_action.py + + - name: Check type hints + run: .venv/bin/mypy --strict vault_oidc_ssh_cert_action.py + + super: name: Super-Linter runs-on: ubuntu-latest + permissions: + contents: read + statuses: write + steps: - name: Checkout uses: actions/checkout@v4 @@ -29,6 +61,10 @@ jobs: uses: super-linter/super-linter/slim@v6 env: VALIDATE_ALL_CODEBASE: true - VALIDATE_SHELL_SHFMT: false + VALIDATE_PYTHON_BLACK: false + VALIDATE_PYTHON_FLAKE8: false + VALIDATE_PYTHON_ISORT: false + VALIDATE_PYTHON_MYPY: false + VALIDATE_PYTHON_PYLINT: false DEFAULT_BRANCH: main GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index e48f4f0..109fd93 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ *~ \#*# .#* + +*.pyc +.venv/ diff --git a/README.md b/README.md index 4eb2754..d2af046 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ jobs: - name: Generate SSH client certificate if: github.ref == 'refs/heads/main' id: ssh_cert - uses: andreaso/vault-oidc-ssh-cert-action@v0.10 + uses: andreaso/vault-oidc-ssh-cert-action@v0.11 with: vault_server: https://vault.example.com:8200 oidc_backend_path: github-oidc diff --git a/action.yaml b/action.yaml index 34f08b3..501edbe 100644 --- a/action.yaml +++ b/action.yaml @@ -27,59 +27,26 @@ inputs: outputs: cert_path: description: Full path to the generated SSH certificate - value: ${{ steps.generator.outputs.cert_path }} + value: ${{ steps.run_action.outputs.cert_path }} key_path: description: Full path to the corresponding private SSH key - value: ${{ steps.generator.outputs.key_path }} + value: ${{ steps.run_action.outputs.key_path }} runs: using: composite steps: - - name: Determine JWT audience - id: determine - run: | - import os - from urllib.parse import urlparse - aud = os.environ["JWT_AUDIENCE"].strip() - if not aud: - url = os.environ["VAULT_SERVER"] - fqdn = urlparse(url).netloc.split(":")[0] - aud = fqdn - with open(os.environ["GITHUB_OUTPUT"], "a") as ghof: - ghof.write(f"audience={aud}\n") + - name: Run Action + id: run_action shell: python + run: | + import vault_oidc_ssh_cert_action + vault_oidc_ssh_cert_action.run() env: + PYTHONPATH: ${{ github.action_path }} JWT_AUDIENCE: ${{ inputs.jwt_audience }} - VAULT_SERVER: ${{ inputs.vault_server }} - - - name: Use GitHub OIDC to authenticate towards Vault - id: vault_auth - shell: bash - run: "${ACTION_PATH}/github-vault-auth" - env: - ACTION_PATH: ${{ github.action_path }} - AUDIENCE: ${{ steps.determine.outputs.audience }} - BACKEND: ${{ inputs.oidc_backend_path }} - ROLE: ${{ inputs.oidc_role }} - VAULT_SERVER: ${{ inputs.vault_server }} - - - name: Generate and sign SSH client certificate - id: generator - shell: bash - run: "${ACTION_PATH}/generate-and-sign" - env: - ACTION_PATH: ${{ github.action_path }} - VAULT_SERVER: ${{ inputs.vault_server }} - VAULT_TOKEN: ${{ steps.vault_auth.outputs.vault_token }} - SSH_BACKEND: ${{ inputs.ssh_backend_path }} + OIDC_BACKEND_PATH: ${{ inputs.oidc_backend_path }} + OIDC_ROLE: ${{ inputs.oidc_role }} + SSH_BACKEND_PATH: ${{ inputs.ssh_backend_path }} SSH_ROLE: ${{ inputs.ssh_role }} - TMPDIR: ${{ runner.temp }} - - - name: Revoke Vault token - if: success() || steps.generator.conclusion == 'failure' - shell: bash - run: | - curl --fail --silent --show-error --tlsv1.3 --header "X-Vault-Token: ${VAULT_TOKEN}" --data "" "${VAULT_SERVER%/}/v1/auth/token/revoke-self" - env: VAULT_SERVER: ${{ inputs.vault_server }} - VAULT_TOKEN: ${{ steps.vault_auth.outputs.vault_token }} + TMPDIR: ${{ runner.temp }} diff --git a/generate-and-sign b/generate-and-sign deleted file mode 100755 index 33e58a0..0000000 --- a/generate-and-sign +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/bash -set -o errexit -set -o nounset -set -o noglob -set -o pipefail - -keyfile="id_github" -pubfile="${keyfile}.pub" -certfile="${keyfile}-cert.pub" -response="response.json" - -workdir=$(mktemp --directory) -trap 'rm -rf "$workdir"' EXIT -cd "$workdir" - -ssh-keygen -q -t ed25519 -N '' -f "./${keyfile}" -pubkey=$(cat "$pubfile") - -vault_server_url="${VAULT_SERVER%/}/v1/${SSH_BACKEND}/sign/${SSH_ROLE}" - -curl \ - --fail \ - --silent \ - --show-error \ - --tlsv1.3 \ - --output "$response" \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --data "{\"public_key\": \"$pubkey\"}" \ - "$vault_server_url" - -jq --exit-status --join-output .data.signed_key "$response" > "$certfile" -ssh-keygen -L -f "$certfile" > /dev/null - -outputs=$(mktemp --tmpdir --directory ssh-cert-XXX) -install --mode=0644 "$certfile" "$outputs" -install --mode=0600 "$keyfile" "$outputs" - -echo "cert_path=${outputs}/${certfile}" >> "$GITHUB_OUTPUT" -echo "key_path=${outputs}/${keyfile}" >> "$GITHUB_OUTPUT" diff --git a/github-vault-auth b/github-vault-auth deleted file mode 100755 index aa3c36c..0000000 --- a/github-vault-auth +++ /dev/null @@ -1,35 +0,0 @@ -#!/bin/bash -set -o errexit -set -o nounset -set -o noglob -set -o pipefail - -github_response=$(mktemp) -vault_response=$(mktemp) -trap 'rm "$github_response" "$vault_response"' EXIT - -curl \ - --fail \ - --silent \ - --show-error \ - --tlsv1.2 \ - --connect-timeout 10 \ - --output "$github_response" \ - --header "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ - "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${AUDIENCE}" - -github_jwt=$(jq --exit-status --raw-output .value "$github_response") - -curl \ - --fail \ - --silent \ - --show-error \ - --tlsv1.3 \ - --connect-timeout 10 \ - --output "$vault_response" \ - --data '{"jwt": "'"$github_jwt"'", "role": "'"$ROLE"'"}' \ - "${VAULT_SERVER%/}/v1/auth/${BACKEND}/login" - -vault_token=$(jq --exit-status --raw-output .auth.client_token "$vault_response") -echo "::add-mask::$vault_token" -echo "vault_token=$vault_token" >> "$GITHUB_OUTPUT" diff --git a/vault_oidc_ssh_cert_action.py b/vault_oidc_ssh_cert_action.py new file mode 100644 index 0000000..510c9ab --- /dev/null +++ b/vault_oidc_ssh_cert_action.py @@ -0,0 +1,208 @@ +import os +import subprocess +import tempfile +import urllib.parse +from typing import List, Tuple + +import requests + + +class VoscaError(Exception): + pass + + +def _mask_value(secret: str) -> None: + print(f"::add-mask::{secret}") + + +def _set_error_message(title: str, message: str) -> None: + print(f"::error title={title}::{message}") + + +def _set_warning_message(title: str, message: str) -> None: + print(f"::warning title={title}::{message}") + + +def _set_step_output(name: str, value: str) -> None: + with open(os.environ["GITHUB_OUTPUT"], mode="a", encoding="utf-8") as ghof: + ghof.write(f"{name}={value}\n") + + +def _check_inputs() -> None: + required_inputs = [ + "oidc_backend_path", + "oidc_role", + "ssh_backend_path", + "ssh_role", + "vault_server", + ] + missing_inputs: List[str] = [] + for input in required_inputs: + if not os.environ.get(input.upper(), "").strip(): + missing_inputs.append(input) + + if not missing_inputs: + return + + title = "Missing Action input(s)" + message = f"Missing required input(s): {','.join(missing_inputs)}" + _set_error_message(title, message) + raise VoscaError(title) + + +def _determine_audience(input_audience: str, vault_server: str) -> str: + if input_audience: + return input_audience + + vault_fqdn = urllib.parse.urlparse(vault_server).netloc.split(":")[0] + return vault_fqdn + + +def _issue_github_jwt(jwt_aud: str) -> str: + try: + req_token = os.environ["ACTIONS_ID_TOKEN_REQUEST_TOKEN"] + req_url = os.environ["ACTIONS_ID_TOKEN_REQUEST_URL"] + except KeyError as key_error: + title = "GitHub Actions workflow/job permission error" + helper_url = "/".join( + [ + "https://docs.github.com/en/actions/deployment", + "security-hardening-your-deployments", + "about-security-hardening-with-openid-connect#adding-permissions-settings", + ] + ) + message = "The `id-token: write` permission appear to be missing." + message += f" See {helper_url} for more info." + _set_error_message(title, message) + raise VoscaError(title) from key_error + + full_url = f"{req_url}&audience={jwt_aud}" + headers = {"Authorization": f"Bearer {req_token}"} + + try: + response = requests.get(full_url, headers=headers, timeout=10) + response.raise_for_status() + except requests.exceptions.RequestException as request_error: + title = "GitHub Actions JWT token issuing error" + message = f"{type(request_error).__name__}: {str(request_error)}" + _set_error_message(title, message) + raise VoscaError(title) from request_error + + jwt_token: str = response.json()["value"] + return jwt_token + + +def _issue_vault_token( + vault_server: str, oidc_backend: str, oidc_role: str, jwt_token: str +) -> str: + login_url = f"{vault_server}/v1/auth/{oidc_backend}/login" + payload = {"jwt": jwt_token, "role": oidc_role} + + try: + response = requests.post(login_url, data=payload, timeout=10) + response.raise_for_status() + except requests.exceptions.RequestException as request_error: + title = "Vault login error" + message = f"{type(request_error).__name__}: {str(request_error)}" + _set_error_message(title, message) + raise VoscaError(title) from request_error + + vault_token: str = response.json()["auth"]["client_token"] + _mask_value(vault_token) + return vault_token + + +def _issue_ssh_cert( + vault_server: str, vault_token: str, ssh_backend: str, ssh_role: str, pubkey: str +) -> str: + issue_url = f"{vault_server}/v1/{ssh_backend}/sign/{ssh_role}" + headers = {"X-Vault-Token": vault_token} + payload = {"public_key": pubkey} + + try: + response = requests.post(issue_url, headers=headers, data=payload, timeout=10) + response.raise_for_status() + except requests.exceptions.RequestException as request_error: + title = "Vault SSH certificate signing error" + message = f"{type(request_error).__name__}: {str(request_error)}" + _set_error_message(title, message) + raise VoscaError(title) from request_error + + ssh_cert: str = response.json()["data"]["signed_key"] + return ssh_cert + + +def _generate_and_sign( + vault_server: str, vault_token: str, ssh_backend: str, ssh_role: str +) -> Tuple[str, str]: + key_fname = "id_github" + cert_fname = f"{key_fname}-cert.pub" + + outdir = tempfile.mkdtemp(prefix="ssh-cert-") + out_key_path = os.path.join(outdir, key_fname) + out_cert_path = os.path.join(outdir, cert_fname) + + with tempfile.TemporaryDirectory(prefix="ssh-keygen-") as workdir: + work_key_path = os.path.join(workdir, key_fname) + work_pub_path = os.path.join(workdir, f"{key_fname}.pub") + work_cert_path = os.path.join(workdir, cert_fname) + + subprocess.run( + ["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", work_key_path], + check=True, + ) + + with open(work_pub_path, mode="r", encoding="utf-8") as pubkf: + pubkey = pubkf.read() + + ssh_cert: str = _issue_ssh_cert( + vault_server, vault_token, ssh_backend, ssh_role, pubkey + ) + with open(work_cert_path, mode="w", encoding="utf-8") as certf: + certf.write(ssh_cert) + + os.rename(work_key_path, out_key_path) + os.rename(work_cert_path, out_cert_path) + + return out_cert_path, out_key_path + + +def _revoke_token(vault_server: str, vault_token: str) -> None: + revoke_url = f"{vault_server}/v1/auth/token/revoke-self" + headers = {"X-Vault-Token": vault_token} + + try: + response = requests.post(revoke_url, headers=headers, timeout=10) + response.raise_for_status() + except requests.exceptions.RequestException as request_error: + title = "Vault token revoke failure" + message = f"{type(request_error).__name__}: {str(request_error)}" + _set_warning_message(title, message) + + +def run() -> None: + _check_inputs() + + input_audience = os.environ["JWT_AUDIENCE"].strip() + oidc_role = os.environ["OIDC_ROLE"].strip() + oidc_backend = os.environ["OIDC_BACKEND_PATH"].strip("/ ") + ssh_role = os.environ["SSH_ROLE"].strip() + ssh_backend = os.environ["SSH_BACKEND_PATH"].strip("/ ") + vault_server = os.environ["VAULT_SERVER"].strip("/ ") + + jwt_aud: str = _determine_audience(input_audience, vault_server) + jwt_token: str = _issue_github_jwt(jwt_aud) + vault_token: str = _issue_vault_token( + vault_server, oidc_backend, oidc_role, jwt_token + ) + + cert_path: str + key_path: str + cert_path, key_path = _generate_and_sign( + vault_server, vault_token, ssh_backend, ssh_role + ) + + _set_step_output("cert_path", cert_path) + _set_step_output("key_path", key_path) + + _revoke_token(vault_server, vault_token)