Skip to content

Commit b0fb383

Browse files
ionmincuclaude
andcommitted
chore: add Claude Code skill for safely extracting Actions secrets
Document the RSA-OAEP encrypted-dump pattern used to recover the cleartext values of repository secrets when the original source isn't available — e.g. when mirroring values from the Actions store into the Dependabot store after enabling pull_request_target. The skill captures the full playbook (keypair → temp workflow step → PR → log scrape → local decrypt → force-push removal) plus the threat model, when to refuse, and rationale for RSA-OAEP over alternatives. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 74e1aa9 commit b0fb383

1 file changed

Lines changed: 198 additions & 0 deletions

File tree

  • .claude/skills/extract-actions-secrets
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
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

Comments
 (0)