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
6 changes: 6 additions & 0 deletions .github/workflows/build-container.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
allow-unsafe-pr-checkout: true

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep fork code out of the package-write workflow

For fork PRs, build.yml invokes this reusable workflow from pull_request_target while granting packages: write; this opt-in now permits the fork-controlled commit to modify the CI Dockerfile and have docker/build-push-action publish the resulting arbitrary image to the repository's GHCR package. persist-credentials: false only removes Git's persisted credential and does not prevent the later docker/login-action from supplying GITHUB_TOKEN for the push. This turns any opened fork PR into a package-publication path; run untrusted heads in a read-only pull_request workflow, or keep the privileged workflow checked out at the trusted base revision.

Useful? React with 👍 / 👎.

persist-credentials: false
Comment on lines 43 to +45

@coderabbitai coderabbitai Bot Jul 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

for f in .github/workflows/build-container.yml .github/workflows/build-depends.yml .github/workflows/build-src.yml .github/workflows/cache-depends-sources.yml .github/workflows/guix-build.yml .github/workflows/lint.yml .github/workflows/test-src.yml; do
  echo "===== $f ====="
  echo "--- triggers ---"
  sed -n '1,120p' "$f" | sed -n '/^on:/,/^[^[:space:]-]/p'
  echo "--- cited regions ---"
  case "$f" in
    .github/workflows/build-container.yml)
      sed -n '1,180p' "$f" | nl -ba | sed -n '35,50p;80,95p;128,142p'
      ;;
    .github/workflows/build-depends.yml)
      sed -n '1,220p' "$f" | nl -ba | sed -n '48,62p;132,146p'
      ;;
    .github/workflows/build-src.yml)
      sed -n '1,140p' "$f" | nl -ba | sed -n '52,68p'
      ;;
    .github/workflows/cache-depends-sources.yml)
      sed -n '1,90p' "$f" | nl -ba | sed -n '18,32p'
      ;;
    .github/workflows/guix-build.yml)
      sed -n '1,170p' "$f" | nl -ba | sed -n '24,38p;85,99p'
      ;;
    .github/workflows/lint.yml)
      sed -n '1,90p' "$f" | nl -ba | sed -n '20,34p'
      ;;
    .github/workflows/test-src.yml)
      sed -n '1,110p' "$f" | nl -ba | sed -n '32,44p'
      ;;
  esac
  echo
done

Repository: dashpay/dash

Length of output: 1057


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show each workflow trigger and the job steps around the referenced checkout blocks.
for f in .github/workflows/build-container.yml .github/workflows/build-depends.yml .github/workflows/build-src.yml .github/workflows/cache-depends-sources.yml .github/workflows/guix-build.yml .github/workflows/lint.yml .github/workflows/test-src.yml; do
  echo "===== $f ====="
  echo "--- trigger and job names ---"
  python3 - <<'PY' "$f"
import sys, yaml, pathlib
path = pathlib.Path(sys.argv[1])
data = yaml.safe_load(path.read_text())
print(data.get("on"))
print("jobs:", list(data.get("jobs", {}).keys()))
PY
  echo "--- cited regions ---"
  case "$f" in
    .github/workflows/build-container.yml)
      nl -ba "$f" | sed -n '35,50p;80,95p;128,142p'
      ;;
    .github/workflows/build-depends.yml)
      nl -ba "$f" | sed -n '48,62p;132,146p'
      ;;
    .github/workflows/build-src.yml)
      nl -ba "$f" | sed -n '52,68p'
      ;;
    .github/workflows/cache-depends-sources.yml)
      nl -ba "$f" | sed -n '18,32p'
      ;;
    .github/workflows/guix-build.yml)
      nl -ba "$f" | sed -n '24,38p;85,99p'
      ;;
    .github/workflows/lint.yml)
      nl -ba "$f" | sed -n '20,34p'
      ;;
    .github/workflows/test-src.yml)
      nl -ba "$f" | sed -n '32,44p'
      ;;
  esac
  echo
done

Repository: dashpay/dash

Length of output: 354


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the workflow sections directly in case Python YAML isn't available.
for f in .github/workflows/build-container.yml .github/workflows/build-depends.yml .github/workflows/build-src.yml .github/workflows/cache-depends-sources.yml .github/workflows/guix-build.yml .github/workflows/lint.yml .github/workflows/test-src.yml; do
  echo "===== $f ====="
  echo "--- first 80 lines ---"
  nl -ba "$f" | sed -n '1,80p'
  echo "--- around cited lines ---"
  case "$f" in
    .github/workflows/build-container.yml)
      nl -ba "$f" | sed -n '35,50p;80,95p;128,142p'
      ;;
    .github/workflows/build-depends.yml)
      nl -ba "$f" | sed -n '48,62p;132,146p'
      ;;
    .github/workflows/build-src.yml)
      nl -ba "$f" | sed -n '52,68p'
      ;;
    .github/workflows/cache-depends-sources.yml)
      nl -ba "$f" | sed -n '18,32p'
      ;;
    .github/workflows/guix-build.yml)
      nl -ba "$f" | sed -n '24,38p;85,99p'
      ;;
    .github/workflows/lint.yml)
      nl -ba "$f" | sed -n '20,34p'
      ;;
    .github/workflows/test-src.yml)
      nl -ba "$f" | sed -n '32,44p'
      ;;
  esac
  echo
done

Repository: dashpay/dash

Length of output: 263


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for f in .github/workflows/build-container.yml .github/workflows/build-depends.yml .github/workflows/build-src.yml .github/workflows/cache-depends-sources.yml .github/workflows/guix-build.yml .github/workflows/lint.yml .github/workflows/test-src.yml; do
  echo "FILE: $f"
  grep -nE '^(on:|  pull_request_target:|  pull_request:|  workflow_dispatch:|jobs:|      - uses: actions/checkout@|        ref:|        allow-unsafe-pr-checkout:|        persist-credentials:|      - run:|      - name: )' "$f" | sed -n '1,120p'
  echo
done

Repository: dashpay/dash

Length of output: 3401


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "allow-unsafe-pr-checkout|pull_request_target|actions/checkout@" .github/workflows

Repository: dashpay/dash

Length of output: 3577


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== callers of workflow_call files ==="
rg -n "uses:\s*\./\.github/workflows/(build-container|build-depends|build-src|cache-depends-sources|guix-build|lint|test-src)\.yml" .github/workflows

echo
echo "=== build.yml trigger + relevant jobs ==="
sed -n '1,220p' .github/workflows/build.yml | awk 'NR>=1 && NR<=220 {print NR ":" $0}'

echo
echo "=== workflow_call declarations for the reviewed files ==="
for f in .github/workflows/build-container.yml .github/workflows/build-depends.yml .github/workflows/build-src.yml .github/workflows/cache-depends-sources.yml .github/workflows/guix-build.yml .github/workflows/lint.yml .github/workflows/test-src.yml; do
  echo "--- $f ---"
  rg -n "^(on:|  workflow_call:|  pull_request_target:|  pull_request:|permissions:|jobs:)" "$f"
done

Repository: dashpay/dash

Length of output: 12381


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for f in .github/workflows/build-container.yml .github/workflows/build-depends.yml .github/workflows/build-src.yml .github/workflows/cache-depends-sources.yml .github/workflows/guix-build.yml .github/workflows/lint.yml .github/workflows/test-src.yml; do
  echo "===== $f ====="
  case "$f" in
    .github/workflows/build-container.yml)
      sed -n '36,50p;84,95p;128,141p' "$f" | awk '{print NR ":" $0}'
      ;;
    .github/workflows/build-depends.yml)
      sed -n '48,60p;134,183p' "$f" | awk '{print NR ":" $0}'
      ;;
    .github/workflows/build-src.yml)
      sed -n '54,66p;120,180p' "$f" | awk '{print NR ":" $0}'
      ;;
    .github/workflows/cache-depends-sources.yml)
      sed -n '20,40p' "$f" | awk '{print NR ":" $0}'
      ;;
    .github/workflows/guix-build.yml)
      sed -n '22,40p;86,125p' "$f" | awk '{print NR ":" $0}'
      ;;
    .github/workflows/lint.yml)
      sed -n '22,46p' "$f" | awk '{print NR ":" $0}'
      ;;
    .github/workflows/test-src.yml)
      sed -n '34,60p' "$f" | awk '{print NR ":" $0}'
      ;;
  esac
  echo
done

Repository: dashpay/dash

Length of output: 11992


Do not run fork PR heads in privileged CI jobs. These reusable workflows are called from .github/workflows/build.yml, which runs on pull_request_target, so github.event.pull_request.head.sha checks out attacker-controlled code under the workflow token. persist-credentials: false does not make that safe.

  • .github/workflows/build-container.yml#L43-L45, #L89-L91, #L134-L136: keep GHCR login/push separate from the untrusted Docker build.
  • .github/workflows/build-depends.yml#L54-L56, #L138-L140; .github/workflows/build-src.yml#L59-L61; .github/workflows/cache-depends-sources.yml#L25-L27; .github/workflows/lint.yml#L26-L28; .github/workflows/test-src.yml#L38-L40; .github/workflows/guix-build.yml#L30-L32, #L91-L93: move the fork-controlled make/script/Guix steps into an unprivileged pull_request workflow.
🧰 Tools
🪛 actionlint (1.7.12)

[error] 44-44: input "allow-unsafe-pr-checkout" is not defined in action "actions/checkout@v6". available inputs are "clean", "fetch-depth", "fetch-tags", "filter", "github-server-url", "lfs", "path", "persist-credentials", "ref", "repository", "set-safe-directory", "show-progress", "sparse-checkout", "sparse-checkout-cone-mode", "ssh-key", "ssh-known-hosts", "ssh-strict", "ssh-user", "submodules", "token"

(action)

📍 Affects 7 files
  • .github/workflows/build-container.yml#L43-L45 (this comment)
  • .github/workflows/build-container.yml#L89-L91
  • .github/workflows/build-container.yml#L134-L136
  • .github/workflows/build-depends.yml#L54-L56
  • .github/workflows/build-depends.yml#L138-L140
  • .github/workflows/build-src.yml#L59-L61
  • .github/workflows/cache-depends-sources.yml#L25-L27
  • .github/workflows/guix-build.yml#L30-L32
  • .github/workflows/guix-build.yml#L91-L93
  • .github/workflows/lint.yml#L26-L28
  • .github/workflows/test-src.yml#L38-L40
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/build-container.yml around lines 43 - 45, Prevent
fork-controlled code from running in privileged pull_request_target jobs. In
.github/workflows/build-container.yml at 43-45, 89-91, and 134-136, separate
GHCR login/push from the untrusted Docker build. Move the fork-controlled
make/script/Guix steps to an unprivileged pull_request workflow in
.github/workflows/build-depends.yml at 54-56 and 138-140, build-src.yml at
59-61, cache-depends-sources.yml at 25-27, lint.yml at 26-28, test-src.yml at
38-40, and guix-build.yml at 30-32 and 91-93.

@PastaPastaPasta PastaPastaPasta Jul 21, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Show an actual exploit / problem, and we can try to make it happen. But we currently need this for our builds.

keep GHCR login/push separate from the untrusted Docker build.

this is only used for ci artifacts. If we can make improvements without breakage, I'm happy to minimize permissions we give tokens etc.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== reusable workflow callers and permissions ==='
rg -n -C 8 \
  'pull_request_target:|permissions:|uses:\s*\./\.github/workflows/build-container\.yml|packages:\s*(write|read)|contents:\s*(write|read)' \
  .github/workflows

echo
echo '=== build-container call sites (full job context) ==='
rg -n -C 16 'uses:\s*\./\.github/workflows/build-container\.yml' .github/workflows

echo
echo '=== GHCR image/tag references and build-container inputs ==='
rg -n -C 4 \
  'ghcr\.io/|build-container\.yml|container_name:|name:.*container|context:|file:' \
  .github/workflows .github Dockerfile* 2>/dev/null || true

echo
echo '=== workflow file inventory ==='
fd -t f -e yml -e yaml .github/workflows | sort

Length of output: 34527


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== CI workflow: container outputs and downstream consumers ==='
sed -n '1,260p' .github/workflows/build.yml | nl -ba

echo
echo '=== container-path consumers and container image declarations ==='
rg -n -C 10 \
  'container-path|container:\s|image:\s*\$\{\{|ghcr\.io/\$\{\{.*dashcore-ci|dashcore-ci-(runner|slim)' \
  .github/workflows

echo
echo '=== relevant reusable-workflow execution and checkout sections ==='
for f in \
  .github/workflows/build-depends.yml \
  .github/workflows/build-src.yml \
  .github/workflows/test-src.yml \
  .github/workflows/lint.yml; do
  echo "--- $f ---"
  rg -n -C 8 'container-path|container:|image:|actions/checkout@v6|run:' "$f"
done

Length of output: 1120


🤖 web_search executed:

GitHub Actions documentation: for a pull_request_target workflow, what are GITHUB_REF and GITHUB_SHA on a pull request from a fork? Also document the security warning about checking out and running pull request head code in pull_request_target.

Length of output: 18827


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== CI workflow (downstream jobs after reusable container calls) ==='
sed -n '90,260p' .github/workflows/build.yml

echo
echo '=== container-path/image consumers ==='
rg -n -C 12 \
  'container-path|container:\s|image:\s*\$\{\{|dashcore-ci-(runner|slim)|ghcr\.io/' \
  .github/workflows

echo
echo '=== reusable workflow container declarations ==='
for f in \
  .github/workflows/build-depends.yml \
  .github/workflows/build-src.yml \
  .github/workflows/test-src.yml \
  .github/workflows/lint.yml; do
  echo "--- $f ---"
  rg -n -C 8 'container-path|container:|image:|actions/checkout@v6|run:' "$f" || true
done

Length of output: 68343


@PastaPastaPasta, there is a concrete exploit path here; I agree my earlier “separate GHCR login/push” wording was incomplete. The core problem is executing the fork’s image/code under pull_request_target, not merely the location of the login step.

A fork PR can:

  1. Modify contrib/containers/ci/ci.Dockerfile or ci-slim.Dockerfile to replace a command used by CI (for example the shell or a build tool) with a wrapper that captures its environment or alters artifacts.
  2. Trigger .github/workflows/build.yml, which is pull_request_target with packages: write.
  3. Have .github/workflows/build-container.yml check out that fork SHA, build it, and push it to the upstream ghcr.io/dashpay/... namespace.
  4. Cause the same run’s downstream jobs to execute that attacker-built image as root:
    • build-depends.yml uses container: image: ${{ inputs.container-path }}
    • build-src.yml, lint.yml, and test-src.yml do the same.
  5. Those jobs then run the fork’s scripts (make, build_src.sh, lint/test scripts) in the attacker-controlled container while retaining the caller workflow’s privileges.

This is not limited to an isolated artifact: the image output is passed directly to the subsequent CI jobs. Additionally, GITHUB_REF on pull_request_target is the base branch, so the manifest is tagged with the base-branch tag and latest; a fork PR can overwrite those shared CI-image tags until a trusted build replaces them.

I would not run a live token-exfiltration proof of concept against the project. GitHub explicitly documents this checkout-and-execute pattern as insecure: https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target. Their actions/checkout v6 documentation added allow-unsafe-pr-checkout specifically because this pattern enables “pwn request” vulnerabilities.

A minimally disruptive direction is:

  • Keep pull_request_target only for non-executing trusted automation.
  • Run fork build/test jobs under pull_request with read-only permissions and no GHCR push.
  • Reserve the current image-publish / privileged CI path for pushes and collaborator-approved PRs (for example, a maintainer-controlled label or a separately dispatched trusted workflow).

That preserves fork CI, but removes the fork → upstream package → privileged execution chain. The existing persist-credentials: false remains good defense-in-depth, but it only removes checkout’s Git credential persistence; it does not change the trust level of the job container or the packages: write token.


- name: Prepare variables
id: prepare
Expand Down Expand Up @@ -85,6 +87,8 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
allow-unsafe-pr-checkout: true
persist-credentials: false

- name: Prepare variables
id: prepare
Expand Down Expand Up @@ -128,6 +132,8 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
allow-unsafe-pr-checkout: true
persist-credentials: false

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/build-depends.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
allow-unsafe-pr-checkout: true
persist-credentials: false
sparse-checkout: |
ci/dash
ci/test
Expand Down Expand Up @@ -134,6 +136,8 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
allow-unsafe-pr-checkout: true
persist-credentials: false

- name: Restore depends sources
uses: actions/cache/restore@v5
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/build-src.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
allow-unsafe-pr-checkout: true
persist-credentials: false
fetch-depth: 50

- name: Initial setup
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/cache-depends-sources.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
allow-unsafe-pr-checkout: true
persist-credentials: false

- name: Check for cached sources
id: cache-check
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/guix-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
allow-unsafe-pr-checkout: true
persist-credentials: false
path: dash
fetch-depth: 0

Expand Down Expand Up @@ -87,6 +89,8 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
allow-unsafe-pr-checkout: true
persist-credentials: false
path: dash
fetch-depth: 0

Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
allow-unsafe-pr-checkout: true
persist-credentials: false
fetch-depth: 50

- name: Initial setup
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/test-src.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
allow-unsafe-pr-checkout: true
persist-credentials: false
fetch-depth: 1

- name: Download build artifacts
Expand Down
Loading