|
| 1 | +--- |
| 2 | +name: extract-actions-secrets |
| 3 | +description: Recover the cleartext values of GitHub Actions repository secrets from a repo the user controls — e.g. to mirror them into the Dependabot secrets store, copy them to another repo, or back them up to a password manager. Uses an RSA-OAEP-encrypted dump emitted by a temporary workflow step, decrypted locally with a maintainer-held private key, then the commit is force-removed. ONLY for repos where the user has admin/maintainer access. Triggers — explicit user requests like "extract the secrets", "get the values from the Actions secret store", "mirror Actions secrets to Dependabot store", "I lost the value of REPO_SECRET_X and need it back". |
| 4 | +--- |
| 5 | + |
| 6 | +# Extract GitHub Actions secrets safely |
| 7 | + |
| 8 | +GitHub does not expose Actions secret values via API/CLI/UI by design. The only way to retrieve a value is to have a workflow step read it at runtime. This skill does that with an encrypted, single-shot pattern that minimizes blast radius. |
| 9 | + |
| 10 | +## When this is the right approach |
| 11 | + |
| 12 | +- User has maintainer/admin access to the repo (writes branch, can open PRs). |
| 13 | +- Original source of the secret is **not** recoverable (UiPath portal / vault / password manager doesn't have it, or whoever set it isn't reachable). |
| 14 | +- User cannot rotate the credential (rotation would make extraction unnecessary — prefer rotation when possible). |
| 15 | + |
| 16 | +## When to refuse / steer the user away |
| 17 | + |
| 18 | +- Repo is not the user's. Do not run this on third-party repos under any framing. |
| 19 | +- The credential is rotatable. Suggest **rotate, write both stores fresh**. It's cleaner: no exfiltration, no log traces. |
| 20 | +- The value is recoverable from the originating system (cloud console, internal vault). Suggest reading from source instead. |
| 21 | + |
| 22 | +## Threat model — what you accept by doing this |
| 23 | + |
| 24 | +1. **Workflow logs contain encrypted ciphertext + the public key.** Useless without the private key, but it's a trace. Delete the workflow run after if you care. |
| 25 | +2. **The temporary commit's SHA remains accessible via direct URL** for ~90 days after force-removal, until GitHub garbage-collects. Not visible in PR/branch history but the commit object exists. |
| 26 | +3. **Force-push rewrites the PR branch.** Coordinate with reviewers if the PR is already under review. |
| 27 | +4. **Private key on the maintainer's laptop.** Treat it like any other secret-holding key: delete after use, never commit. |
| 28 | + |
| 29 | +## Playbook |
| 30 | + |
| 31 | +### Step 1 — Generate a keypair on the maintainer's laptop |
| 32 | + |
| 33 | +```bash |
| 34 | +WORKDIR="$HOME/.exfil-keys/$(basename "$PWD")" |
| 35 | +mkdir -p "$WORKDIR" |
| 36 | +openssl genrsa -out "$WORKDIR/private.pem" 4096 |
| 37 | +openssl rsa -in "$WORKDIR/private.pem" -pubout -out "$WORKDIR/public.pem" |
| 38 | +chmod 600 "$WORKDIR/private.pem" |
| 39 | +cat "$WORKDIR/public.pem" |
| 40 | +``` |
| 41 | + |
| 42 | +The private key never leaves the laptop. The public PEM gets inlined into the workflow step below. |
| 43 | + |
| 44 | +### Step 2 — Identify the secrets to extract |
| 45 | + |
| 46 | +```bash |
| 47 | +grep -oE 'secrets\.[A-Z0-9_]+' .github/workflows/*.yml | sort -u |
| 48 | +``` |
| 49 | + |
| 50 | +Pick the ones you need. List them by their `secrets.NAME` identifier. |
| 51 | + |
| 52 | +### Step 3 — Add a temporary workflow step |
| 53 | + |
| 54 | +Find a job that already runs with secrets in scope on `pull_request` events (typically an integration-tests / e2e job — anything that already reads from `secrets.*`). Add a step **before** the main test step: |
| 55 | + |
| 56 | +```yaml |
| 57 | +- name: Encrypt and emit secrets for one-shot exfiltration (TEMPORARY — REMOVE) |
| 58 | + if: github.event_name != 'push' && <SCOPE CONDITION TO RUN ONCE> |
| 59 | + shell: bash |
| 60 | + env: |
| 61 | + PUBKEY: | |
| 62 | + -----BEGIN PUBLIC KEY----- |
| 63 | + <PASTE THE PUBLIC PEM CONTENTS HERE, KEEPING EACH LINE INDENTED UNDER PUBKEY> |
| 64 | + -----END PUBLIC KEY----- |
| 65 | + # One env var per secret you want to extract. Use the same names you grepped. |
| 66 | + SECRET_A: ${{ secrets.SECRET_A }} |
| 67 | + SECRET_B: ${{ secrets.SECRET_B }} |
| 68 | + run: | |
| 69 | + command -v openssl >/dev/null || { apt-get update -qq && apt-get install -y -qq openssl; } |
| 70 | + echo "$PUBKEY" > /tmp/pubkey.pem |
| 71 | +
|
| 72 | + emit() { |
| 73 | + local name="$1" |
| 74 | + local value="$2" |
| 75 | + if [ -z "$value" ]; then |
| 76 | + echo "::warning::$name is empty; skipping" |
| 77 | + return |
| 78 | + fi |
| 79 | + local b64 |
| 80 | + b64=$(printf '%s' "$value" \ |
| 81 | + | openssl pkeyutl -encrypt -pubin -inkey /tmp/pubkey.pem \ |
| 82 | + -pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha256 \ |
| 83 | + | base64 -w0) |
| 84 | + echo "ENCRYPTED <label>.$name=$b64" |
| 85 | + } |
| 86 | +
|
| 87 | + emit SECRET_A "$SECRET_A" |
| 88 | + emit SECRET_B "$SECRET_B" |
| 89 | +
|
| 90 | + rm -f /tmp/pubkey.pem |
| 91 | +``` |
| 92 | +
|
| 93 | +Replace `<label>` with a tag for grouping (e.g. `alpha`, `staging`, `prod`). Replace `<SCOPE CONDITION TO RUN ONCE>` with whatever scopes this to a single matrix slot — e.g. `matrix.testcase == 'datetime-server' && matrix.environment == 'alpha'`. Without that, the step runs once per matrix entry and floods the log. |
| 94 | + |
| 95 | +Notes on the YAML: |
| 96 | +- `shell: bash` is required if the job's container image lacks bash on the default shell path (it'll fall back to `sh -e` which doesn't support `local`/arrays/heredoc). |
| 97 | +- RSA-OAEP-4096 max payload is ~446 bytes — fine for OAuth client secrets, GUIDs, URLs. For larger payloads, switch to a hybrid scheme (random AES key encrypted with RSA, then payload encrypted with AES). |
| 98 | +- `if: github.event_name != 'push'` keeps it from running on direct pushes to main, just in case. |
| 99 | + |
| 100 | +### Step 4 — Commit & push, ensure a PR exists |
| 101 | + |
| 102 | +```bash |
| 103 | +git checkout -b exfil/<scope-name> |
| 104 | +git add .github/workflows/<the-workflow>.yml |
| 105 | +git commit -m "ci: TEMPORARY — emit RSA-OAEP encrypted secrets (REMOVE)" |
| 106 | +git push -u origin exfil/<scope-name> |
| 107 | +gh pr create --title "ci: temporary secret exfiltration" --body "Will be force-removed." |
| 108 | +``` |
| 109 | + |
| 110 | +A PR is required (not a direct push) so the `pull_request` event fires and secrets are in scope. (Or use any existing PR's branch — push the temp commit there, run it, then revert.) |
| 111 | + |
| 112 | +### Step 5 — Wait for the target job to finish |
| 113 | + |
| 114 | +```bash |
| 115 | +gh run list --branch exfil/<scope-name> --workflow=<workflow.yml> --limit 1 |
| 116 | +# Note the run ID |
| 117 | +gh run view <run-id> --json jobs | jq '.jobs[] | {name, databaseId, status, conclusion}' |
| 118 | +# Pick the job that contained your encryption step. Poll: |
| 119 | +until [ "$(gh api repos/<owner>/<repo>/actions/jobs/<job-id> --jq '.status')" = "completed" ]; do |
| 120 | + sleep 15 |
| 121 | +done |
| 122 | +``` |
| 123 | + |
| 124 | +### Step 6 — Pull encrypted lines from the log |
| 125 | + |
| 126 | +```bash |
| 127 | +gh api repos/<owner>/<repo>/actions/jobs/<job-id>/logs \ |
| 128 | + | grep -oE 'ENCRYPTED [a-z_]+\.[A-Z_]+=[A-Za-z0-9+/=]+' \ |
| 129 | + > "$WORKDIR/encrypted.txt" |
| 130 | +wc -l "$WORKDIR/encrypted.txt" |
| 131 | +awk -F= '{print $1}' "$WORKDIR/encrypted.txt" |
| 132 | +``` |
| 133 | + |
| 134 | +The tighter `grep -oE` pattern matters — a looser pattern matches the echoed script source as well. |
| 135 | + |
| 136 | +### Step 7 — Decrypt locally |
| 137 | + |
| 138 | +```bash |
| 139 | +IN="$WORKDIR/encrypted.txt" OUT="$WORKDIR/decrypted.txt" KEY="$WORKDIR/private.pem" bash -c ' |
| 140 | +set -euo pipefail |
| 141 | +: > "$OUT" |
| 142 | +count=0 |
| 143 | +while IFS= read -r line || [ -n "$line" ]; do |
| 144 | + # Strip trailing CR if the input has CRLF line endings (common on Windows) |
| 145 | + line="${line%$'"'"'\r'"'"'}" |
| 146 | + [ -z "$line" ] && continue |
| 147 | + case "$line" in "ENCRYPTED "*) ;; *) continue ;; esac |
| 148 | + name="${line#ENCRYPTED }" |
| 149 | + b64="${name#*=}" |
| 150 | + name="${name%%=*}" |
| 151 | + cleartext=$(printf "%s" "$b64" | base64 -d | openssl pkeyutl -decrypt \ |
| 152 | + -inkey "$KEY" \ |
| 153 | + -pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha256) |
| 154 | + printf "%s=%s\n" "$name" "$cleartext" >> "$OUT" |
| 155 | + count=$((count + 1)) |
| 156 | +done < "$IN" |
| 157 | +chmod 600 "$OUT" |
| 158 | +echo "Decrypted $count value(s) into $OUT" |
| 159 | +' |
| 160 | +``` |
| 161 | + |
| 162 | +Inspect `$WORKDIR/decrypted.txt` in an editor — do not `cat` or pipe it through chat/logs. |
| 163 | + |
| 164 | +### Step 8 — Verify, then clean up (all three) |
| 165 | + |
| 166 | +1. **Mirror the values** wherever they need to go (Dependabot store, another repo, password manager). Test that at least one credential works before destroying anything. |
| 167 | + |
| 168 | +2. **Force-remove the commit** from the branch: |
| 169 | + ```bash |
| 170 | + git reset --hard HEAD~1 |
| 171 | + git push --force-with-lease |
| 172 | + ``` |
| 173 | + |
| 174 | +3. **Optionally delete the workflow run** so the encrypted log lines don't sit around: |
| 175 | + ```bash |
| 176 | + gh run delete <run-id> |
| 177 | + ``` |
| 178 | + |
| 179 | +4. **Delete the private key:** |
| 180 | + ```bash |
| 181 | + rm -rf "$WORKDIR" |
| 182 | + ``` |
| 183 | + |
| 184 | +## Variations |
| 185 | + |
| 186 | +- **Multiple labels in one shot:** repeat the env block per environment (`ALPHA_*`, `STAGING_*`, `CLOUD_*`) and call `emit alpha.SECRET_X "$ALPHA_X"` etc. One step, one log section, one decrypt pass. |
| 187 | +- **No suitable secret-bearing job exists yet:** add a one-off job `if: github.event_name == 'pull_request'` with the secrets you need. Still scope by a single matrix entry if applicable. |
| 188 | +- **Repo is private and logs are restricted to org members:** the encryption is still worth doing — defense-in-depth — but the urgency is lower. |
| 189 | + |
| 190 | +## Why not just write secrets to an artifact? |
| 191 | + |
| 192 | +Workflow artifacts on public repos are **publicly downloadable by any authenticated GitHub user**. GitHub Actions masks secrets in log output but does *not* mask the content of files written by steps or uploaded as artifacts. A plaintext artifact on a public repo is a leak. The encrypted-log approach avoids this — even if a stranger scrapes the log, they get ciphertext. |
| 193 | + |
| 194 | +## Why RSA-OAEP and not symmetric / age / GPG? |
| 195 | + |
| 196 | +- **age** is cleanest but requires the binary, which isn't pre-installed in most CI images. |
| 197 | +- **GPG** is heavy; key management is fussy. |
| 198 | +- **OpenSSL with RSA-OAEP** is universally pre-installed (or trivially `apt-get install`-able on Debian-based runners) and `openssl pkeyutl` is a one-liner. The payloads here are small enough that RSA's size limit isn't a problem. |
0 commit comments