Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 85 additions & 12 deletions .github/workflows/run-script.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,48 @@ on:
description: "Restore the pre-commit cache, saving it on non-PR events"
type: boolean
default: false
uv-cache:
description: "Restore uv's cache, saving it on non-PR events"
type: boolean
default: false
full-git-history:
type: boolean
description: "Whether to fetch full git history rather than just current HEAD"
default: false
github-token:
type: boolean
description: "Expose the job's read-only GitHub token to the script as GH_TOKEN"
description: "Expose the job's GitHub token to the script as GH_TOKEN. It carries whatever permissions the caller grants, so grant only what the script needs"
default: false
mise-env:
type: string
description: "Value for MISE_ENV, selecting additional mise.<env>.toml config files"
default: ""
aws-role:
type: string
description: "ARN of an AWS role to assume through GitHub OIDC before the script runs. The caller must grant id-token: write"
default: ""
aws-region:
type: string
description: "AWS region for the credentials aws-role yields"
default: "us-east-1"
secrets:
STEP_SECURITY_API_KEY:
required: false
permissions: {}
SSH_PRIVATE_KEY:
description: "SSH private key to install for the script, e.g. a deploy key for cloning a private repository"
required: false
# No permissions here: the job takes exactly what the caller grants. That is
# contents: read for most callers, plus id-token: write for those passing
# aws-role. A called workflow cannot be granted more than its caller has, and
# asking for id-token: write here would fail every caller that doesn't grant it.
# The caller's grant is therefore the only cap on the token github-token
# exposes, so callers must grant no more than contents: read (and id-token).
jobs:
run:
runs-on: ubuntu-latest
permissions:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Flag Security Vulnerabilities

Removing this cap lets a caller with write-enabled defaults expose a write-scoped token as GH_TOKEN when github-token is enabled, despite the input describing it as read-only. Keep the script’s token read-only, including when adding optional OIDC permissions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At .github/workflows/run-script.yml, line 32:

<comment>Removing this cap lets a caller with write-enabled defaults expose a write-scoped token as `GH_TOKEN` when `github-token` is enabled, despite the input describing it as read-only. Keep the script’s token read-only, including when adding optional OIDC permissions.</comment>

<file context>
@@ -22,17 +26,30 @@ on:
+      aws-role:
+        type: string
+        description: "ARN of an AWS role to assume through GitHub OIDC before the script runs. The caller must grant id-token: write"
+        default: ""
+      aws-region:
+        type: string
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing this is required for callers to be able to pass the required permissions (based on which features are enabled).

This is only an issue in case where a third party component (script, library, pre-commit hook) are compromised in a way that they extract secrets (like the GH token).

A third-party PR from a fork on a public repo won't run into this issue.

contents: read
env:
HARDEN_RUNNER: ${{ secrets.STEP_SECURITY_API_KEY != '' }} # zizmor: ignore[secrets-outside-env]
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY != '' }} # zizmor: ignore[secrets-outside-env]
MISE_ENV: ${{ inputs.mise-env }}
MISE_LOCKED: "1"
steps:
Expand All @@ -49,38 +68,92 @@ jobs:
persist-credentials: false
fetch-depth: ${{ inputs.full-git-history && '0' || '1' }}

- name: Configure AWS credentials
if: inputs.aws-role != ''
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
with:
role-to-assume: ${{ inputs.aws-role }}
aws-region: ${{ inputs.aws-region }}

# The runner's known_hosts already covers github.com. The key goes in a
# file of its own, named in ~/.ssh/config, so it works whatever its type.
- name: Install SSH key
if: env.SSH_KEY == 'true'
run: |
umask 077
mkdir -p ~/.ssh
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/run-script-key
printf 'Host *\n IdentityFile ~/.ssh/run-script-key\n' >> ~/.ssh/config
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} # zizmor: ignore[secrets-outside-env]

- name: Install mise
uses: jdx/mise-action@7a4e45a543138629540c9a1616d08632b893e492 # v5.0.1
with:
minimum_release_age: 7d
cache: true

# Always read the cache, it's safe to do so even on untrusted PRs.
# Caches are restored on every run except pull requests from forks of a
# public repository: a trusted run may have filled them with private
# artifacts (packages from a private registry, clones of private hook
# repositories), which an outsider's script could read and exfiltrate.
# Forks of a private repository belong to people who can already read it.
#
# Hook environments are built with the toolchains mise installs (Python,
# Node, ...), so the key covers the mise config and lockfiles and the
# selected environment as well as the hook pins: a toolchain bump must
# not restore environments built against the old one.
- name: Restore pre-commit cache
if: inputs.pre-commit-cache
if: inputs.pre-commit-cache && !(github.event.pull_request.head.repo.fork && !github.event.repository.private)
id: pre-commit-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/pre-commit
key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }}
key: pre-commit-${{ runner.os }}-${{ runner.arch }}-${{ inputs.mise-env }}-${{ hashFiles('.pre-commit-config.yaml', 'mise.toml', 'mise.lock', 'mise.*.toml', 'mise.*.lock') }}

# uv verifies what it installs against uv.lock's hashes, so a stale entry
# only costs a download; restore the newest cache when the lock moved.
- name: Restore uv cache
if: inputs.uv-cache && !(github.event.pull_request.head.repo.fork && !github.event.repository.private)
id: uv-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/uv
key: uv-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('uv.lock') }}
restore-keys: |
uv-${{ runner.os }}-${{ runner.arch }}-

- name: Run
# Some tools reject an empty GH_TOKEN (e.g. zizmor), so unset it rather
# than leaving it empty.
run: | # zizmor: ignore[template-injection] caller-provided script
[ -n "$GH_TOKEN" ] || unset GH_TOKEN
# Point prek at the pre-commit cache path so the cache step covers
# both. Exported here because `env:` values don't expand `~`.
export PREK_HOME="$HOME/.cache/pre-commit"
${{ inputs.run }}
env:
GH_TOKEN: ${{ inputs.github-token && github.token || '' }}
# Point prek at the pre-commit cache path so the cache step covers both.
PREK_HOME: ~/.cache/pre-commit

# Cache ssave is gated to non-PR events: an untrusted PR run must not be
# able to write a hook-environment cache that a later trusted build
# restores and executes (cache poisoning).
# Cache saves are gated to non-PR events: an untrusted PR run must not be
# able to write a cache that a later trusted build restores and executes
# (cache poisoning).
- name: Save pre-commit cache
if: inputs.pre-commit-cache && github.event_name != 'pull_request' && steps.pre-commit-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/pre-commit
key: ${{ steps.pre-commit-cache.outputs.cache-primary-key }}

# Keep only what's slow to rebuild: --ci drops pre-built wheels, which
# download again quickly, and keeps wheels built from source.
- name: Prune uv cache
if: inputs.uv-cache && github.event_name != 'pull_request' && steps.uv-cache.outputs.cache-hit != 'true'
run: uv cache prune --ci

- name: Save uv cache
if: inputs.uv-cache && github.event_name != 'pull_request' && steps.uv-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/uv
key: ${{ steps.uv-cache.outputs.cache-primary-key }}
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,39 @@ are loaded on top of the default config.
just lint
```

Set `pre-commit-cache` to keep pre-commit's (or prek's) hook environments between
runs, and `uv-cache` to keep uv's cache. Both are saved only off pull requests, so an
untrusted pull request can't write a cache a later trusted run restores, and restored on
every run except pull requests from forks of a public repository, since a trusted run
may have filled them with private packages or hook repositories.

The job runs with exactly the permissions the caller grants, so grant
`contents: read` and nothing more (plus `id-token: write` for `aws-role`): with
`github-token` set, the script gets a token carrying all of them.

Set `aws-role` to assume that AWS role through GitHub OIDC before the script runs,
with `aws-region` if it isn't `us-east-1`. The caller must then grant `id-token: write`
as well.

Pass `SSH_PRIVATE_KEY` to give the script an SSH key, such as a deploy key that
pre-commit needs to clone a private hook repository over SSH.

```
Lint:
permissions:
contents: read
id-token: write
uses: stacklet/github-tooling/.github/workflows/run-script.yml@<commit-sha>
secrets:
STEP_SECURITY_API_KEY: ${{ secrets.STEP_SECURITY_API_KEY }}
SSH_PRIVATE_KEY: ${{ secrets.PRECOMMIT_SSH_KEY }}
with:
aws-role: arn:aws:iam::123456789012:role/ci
pre-commit-cache: true
run: |
just lint
```

### `cut-release`

Works out the next version from the tag history and publishes a GitHub release for it,
Expand Down
Loading