chore: publish from staged

This commit is contained in:
github-actions[bot]
2026-06-16 01:04:53 +00:00
parent 85fe01d870
commit 699cefcea9
7 changed files with 548 additions and 0 deletions
@@ -0,0 +1,86 @@
# Script Injection
`${{ <expr> }}` is substituted into the script **as text, before the shell runs**. Any expression
that resolves to data an outside contributor controls is therefore a command-injection sink.
## Attacker-Controllable Contexts
These can be set by anyone who can open an issue, PR, or comment:
| Context | Set by |
| --- | --- |
| `github.event.issue.title` / `.body` | Issue author |
| `github.event.pull_request.title` / `.body` | PR author |
| `github.event.pull_request.head.ref` / `.head.label` | PR author (branch name) |
| `github.head_ref` | PR author (branch name) |
| `github.event.comment.body` | Commenter |
| `github.event.review.body` / `.review_comment.body` | Reviewer |
| `github.event.commits.*.message` / `head_commit.message` | Commit author |
| `github.event.commits.*.author.email` / `.name` | Commit author |
| `github.event.pages.*.page_name` | Wiki editor |
A branch named `$(<attacker-command>)` or an issue titled `"; <attacker-command> #` becomes shell
when interpolated into a `run:` step.
## The Vulnerable Pattern
```yaml
# VULNERABLE
- run: |
echo "Reviewing PR: ${{ github.event.pull_request.title }}"
git checkout ${{ github.head_ref }}
```
## The Safe Pattern — Pass Through `env:`
Bind the untrusted value to an environment variable, then reference the *shell* variable (quoted).
The shell variable is data, never re-parsed as workflow syntax:
```yaml
# SAFE
- env:
PR_TITLE: ${{ github.event.pull_request.title }}
HEAD_REF: ${{ github.head_ref }}
run: |
echo "Reviewing PR: $PR_TITLE"
git checkout "$HEAD_REF"
```
`${{ }}` now appears only on the `env:` side, where it is assigned as a value rather than spliced
into a command. Always quote the shell variable (`"$PR_TITLE"`) to prevent word-splitting and
globbing.
## `actions/github-script`
The same rule applies. Do not interpolate `${{ }}` into the `script:` body — pass it through the
environment and read `process.env`:
```yaml
# VULNERABLE
- uses: actions/github-script@<sha>
with:
script: console.log("${{ github.event.issue.title }}")
# SAFE
- uses: actions/github-script@<sha>
env:
TITLE: ${{ github.event.issue.title }}
with:
script: console.log(process.env.TITLE)
```
## Custom Action Inputs
Passing untrusted `${{ }}` into a composite or JS action's `with:` inputs can be safe or not
depending on whether the action itself interpolates the input into a shell. When in doubt, pass via
`env:` and have the action read the environment, or sanitize/validate first (e.g. a branch name
should match `^[A-Za-z0-9._/-]+$`).
## Quick Audit Checklist
1. Grep every `run:` and `script:` for `${{`.
2. For each, resolve what the expression points to.
3. If it can be set by a non-collaborator → rewrite via `env:` with a quoted shell variable.
4. `github.actor`, `github.repository`, `github.sha`, `github.ref` (for branch protection contexts)
and similar server-controlled values are not attacker-set, but a defense-in-depth `env:` rewrite
costs nothing.
@@ -0,0 +1,76 @@
# Permissions and Tokens
Every workflow run gets an automatic `GITHUB_TOKEN`. Its scope is the blast radius if a step is
compromised, so scope it to the minimum.
## The Default Is Too Broad
If a workflow has no `permissions:` block, it inherits the repository/organization default. On
older or permissive repos that default is **read/write to most scopes**. A single injected command
or malicious dependency then runs with the ability to push code, publish releases, or approve PRs.
## Least-Privilege Recipe
Set a restrictive default at the top level, then elevate per job only where needed.
```yaml
# Deny by default
permissions: {}
jobs:
build:
permissions:
contents: read # checkout only
runs-on: ubuntu-latest
steps: [...]
comment:
permissions:
contents: read
pull-requests: write # this job posts a comment; nothing else
runs-on: ubuntu-latest
steps: [...]
```
Common scopes: `contents`, `pull-requests`, `issues`, `actions`, `packages`, `id-token`,
`deployments`, `checks`, `statuses`. Each is `read`, `write`, or `none`.
## Findings to Flag
* No `permissions:` block anywhere → MEDIUM (inherits possibly-broad default).
* `permissions: write-all` → HIGH.
* A `write` scope the job's steps never use → HIGH (drop it).
* Top-level `write` that should live on one job → MEDIUM (move it down).
## OIDC Instead of Long-Lived Cloud Secrets
Storing static cloud keys (`AWS_ACCESS_KEY_ID`, etc.) as repo secrets means a leak is permanent
until manually rotated. Prefer OpenID Connect: the workflow requests a short-lived token the cloud
provider trusts, scoped to that repo/branch, expiring in minutes.
```yaml
permissions:
id-token: write # required to request the OIDC token
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@<sha>
with:
role-to-assume: arn:aws:iam::123456789012:role/my-ci-role
aws-region: us-east-1
# no AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY secrets needed
```
The same pattern exists for Azure (`azure/login`), GCP (`google-github-actions/auth`), HashiCorp
Vault, and others. On the cloud side, scope the trust policy to the specific repo and ideally a
specific branch/environment so a fork or another repo cannot assume the role.
## Secret Hygiene
* Reference secrets only in the jobs that need them.
* Never `echo` a secret or enable shell tracing (`set -x`) in a step that handles one.
* Don't pass secrets into third-party actions you haven't pinned and reviewed.
* Remember fork `pull_request` runs get no secrets — don't try to "fix" that by switching to
`pull_request_target` (see `triggers-and-privilege.md`).
@@ -0,0 +1,65 @@
# Report Format
Use this structure for every workflow hardening review.
## 1. Summary Table (always first)
```
GitHub Actions Hardening — <workflow file(s) reviewed>
| Severity | Count |
| ---------- | ----- |
| 🔴 CRITICAL | 1 |
| 🟠 HIGH | 2 |
| 🟡 MEDIUM | 1 |
| 🔵 LOW | 1 |
| ⚪ INFO | 0 |
```
If nothing was found: `No issues found. Checked: triggers, injection sinks, permissions, action
pinning, secret handling.`
## 2. Findings (grouped by issue type, not by file)
For each finding use a card:
```
### 🔴 CRITICAL — Script injection via PR title on a privileged trigger
File: .github/workflows/triage.yml (line 14)
Trigger: pull_request_target
Offending code:
- run: echo "New PR: ${{ github.event.pull_request.title }}"
Risk: pull_request_target runs with a read/write token and repository secrets, and any
contributor can open a PR with a title like "; <attacker-command> # which is executed as shell.
This allows secret exfiltration and pushes with the workflow token.
Fix:
- env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "New PR: $PR_TITLE"
Confidence: High
```
## 3. Remediation Blocks
Every CRITICAL and HIGH finding includes a concrete before/after. Preserve the author's
indentation, step names, and surrounding structure — change only what fixes the issue, and add a
one-line comment explaining the change where it isn't obvious.
## 4. Closing Note
End with the explicit line:
> Review each change before committing. Nothing has been modified.
## Style Rules
* Quote the exact offending line and give its location.
* Explain risk in plain English — what an attacker actually does, not just the rule name.
* Per-finding confidence: High / Medium / Low.
* Don't inflate severity: a fork `pull_request` (read-only token, no secrets) running untrusted
code is not CRITICAL on its own.
@@ -0,0 +1,71 @@
# Supply Chain
A workflow runs other people's code every time it `uses:` an action. Those actions execute with
your token and (on privileged triggers) your secrets, so their integrity is your integrity.
## Pin Third-Party Actions to a Commit SHA
Tags (`@v4`) and branches (`@main`) are **mutable** — the upstream owner (or anyone who compromises
them) can repoint them to new code without you changing a line. A full 40-character commit SHA is
immutable.
```yaml
# Mutable — the tag can be moved to malicious code
- uses: some-org/some-action@v3
# Pinned — this exact tree, forever
- uses: some-org/some-action@3f1e0a9c8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f # v3.2.1
```
Rules:
* Third-party actions (anything not `actions/*` or `github/*`) → **MUST** be SHA-pinned. Flag tags
and branches as HIGH.
* `@main` / `@master` → HIGH regardless of publisher; that is unversioned "latest".
* First-party `actions/*` → SHA-pinning is the hardened recommendation (LOW if only tag-pinned).
* Keep a trailing `# vX.Y.Z` comment so humans and Dependabot can read the intended version.
This is not theoretical: real incidents have seen popular actions' tags repointed to code that
exfiltrated secrets from every workflow that referenced the mutable tag.
## Let Dependabot Update the Pins
SHA pins go stale. Enable Dependabot for the `github-actions` ecosystem so updates arrive as
reviewable PRs (it understands the `# vX.Y.Z` comment and bumps the SHA):
```yaml
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
```
## Artifact and Cache Poisoning
* An artifact uploaded by an untrusted `pull_request` build is **untrusted data**. A privileged
`workflow_run` may download it, but must treat it as data only — never execute it, and validate
paths when extracting (a crafted artifact can contain `../` path-traversal entries).
* Caches are keyed and can be populated by less-privileged runs; do not trust cached build outputs
to be untampered in a privileged context.
## Self-Hosted Runners on Public Repos
Default (GitHub-hosted) runners are ephemeral — a fresh VM per job, destroyed after. **Self-hosted
runners persist**, so untrusted fork PR code running on one can:
* Leave behind tools/backdoors for the next job,
* Read other repositories' checkouts or credentials on the same machine,
* Pivot into your network.
Never use self-hosted runners for workflows that public forks can trigger. If you must, use
ephemeral, isolated, single-use runners and never expose secrets to fork-triggered jobs.
## `checkout` Credential Persistence
`actions/checkout` writes the token into `.git/config` by default so later `git` steps can push.
If the job subsequently runs untrusted code, that code can read the token. Set
`persist-credentials: false` when you don't need to push, especially before running build/test of
untrusted code.
@@ -0,0 +1,89 @@
# Triggers and Privilege
The single most important question for workflow security is: **can an outside contributor trigger
this workflow, and if so, what token and secrets does it get?** GitHub answers this differently per
trigger.
## Trust Matrix
| Trigger | Who can fire it | `GITHUB_TOKEN` | Secrets available | Risk |
| --- | --- | --- | --- | --- |
| `push` | Repo collaborators | read/write | yes | Low — trusted authors |
| `pull_request` (same-repo branch) | Collaborators | read/write | yes | Low |
| `pull_request` (from a fork) | **Anyone** | **read-only** | **no** | Low by design — even malicious code can't steal anything |
| `pull_request_target` | **Anyone with a fork** | **read/write** | **yes** | **High** — runs in base-repo context |
| `workflow_run` | Fires after another workflow | **read/write** | **yes** | **High** |
| `issue_comment`, `issues` | **Anyone** | **read/write** | **yes** | **High** |
The trap: `pull_request` from a fork is *safe* because GitHub deliberately strips the token down
and withholds secrets. Maintainers who find that "the secrets don't work on fork PRs" often switch
to `pull_request_target` to get them back — and in doing so hand a write token and every secret to
arbitrary contributors.
## Why `pull_request_target` Is Dangerous
`pull_request_target` checks out the **base** repository's workflow definition (so a fork can't
change what runs), but it runs with full privileges. The danger is when the workflow then
explicitly checks out the **fork's** code and executes it:
```yaml
# DANGEROUS — RCE with a write token + secrets
on: pull_request_target
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<sha>
with:
ref: ${{ github.event.pull_request.head.sha }} # fork's code
- run: npm install && npm test # runs the fork's code + scripts
```
`npm install` alone runs arbitrary lifecycle scripts from the PR. With `pull_request_target` those
scripts can read `secrets.*` and push commits with the write token.
## The Safe Two-Workflow Pattern
Split responsibilities. An **unprivileged** workflow runs the untrusted code; a **privileged**
workflow consumes only the trusted *output*.
```yaml
# 1) Unprivileged: runs untrusted code, no secrets, read-only token
name: PR Build
on: pull_request
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<sha>
- run: npm ci && npm run build
- uses: actions/upload-artifact@<sha>
with: { name: pr, path: dist/ }
```
```yaml
# 2) Privileged: triggered by the first, never runs fork code
name: PR Comment
on:
workflow_run:
workflows: ["PR Build"]
types: [completed]
permissions:
pull-requests: write
jobs:
comment:
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@<sha> # data only, not executed
# post results, using the trusted token — but never execute the artifact
```
## Rules
* Treat `pull_request_target`, `workflow_run`, `issue_comment`, and `issues` as privileged.
* In a privileged workflow, **never** check out and execute PR/fork code.
* If you only need to label, comment, or triage based on metadata, that is fine — just don't run
the contributor's code.
* Prefer `pull_request` (with its safe read-only/no-secrets defaults) whenever possible.