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
33 changes: 31 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,10 @@ jobs:
echo "::error title=Coverage::coverage/lcov.info is missing or empty"
exit 1
fi
# Direct upload for trusted contexts only (push + same-repo PRs), where secrets.CODECOV_TOKEN is
# available. Fork PRs skip this — they cannot read the token — and take the artifact path below.
- name: Upload coverage to Codecov
if: ${{ success() && (github.event_name == 'push' || needs.changes.outputs.backend == 'true') }}
if: ${{ success() && (github.event_name == 'push' || needs.changes.outputs.backend == 'true') && github.event.pull_request.head.repo.fork != true }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
Expand All @@ -153,7 +155,7 @@ jobs:
# their upload non-blocking so a JUnit ingestion hiccup does not fail CI
# after the tests and hard coverage upload have already passed.
- name: Upload Vitest results to Codecov
if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.backend == 'true') }}
if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.backend == 'true') && github.event.pull_request.head.repo.fork != true }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
Expand All @@ -164,6 +166,33 @@ jobs:
override_commit: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
override_pr: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || '' }}
fail_ci_if_error: false
# Fork PRs run without secrets, so they cannot upload to Codecov here. Stash the coverage report and the
# PR metadata as an artifact; the privileged `Codecov fork upload` workflow (workflow_run, base-repo
# context, has the token) uploads it — keeping codecov/patch enforced on forks. Fork-controlled values
# (branch name) go through env, never shell interpolation, and are re-validated before use downstream.
- name: Stash fork coverage for out-of-band upload
if: ${{ success() && needs.changes.outputs.backend == 'true' && github.event.pull_request.head.repo.fork == true }}
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
HEAD_BRANCH: ${{ github.event.pull_request.head.ref }}
run: |
mkdir -p fork-coverage
cp coverage/lcov.info fork-coverage/lcov.info
cp reports/junit/vitest.xml fork-coverage/vitest.xml 2>/dev/null || true
{
printf 'PR_NUMBER=%s\n' "$PR_NUMBER"
printf 'HEAD_SHA=%s\n' "$HEAD_SHA"
printf 'HEAD_BRANCH=%s\n' "$HEAD_BRANCH"
} > fork-coverage/codecov-meta.env
- name: Upload fork coverage artifact
if: ${{ success() && needs.changes.outputs.backend == 'true' && github.event.pull_request.head.repo.fork == true }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: fork-coverage
path: fork-coverage/
retention-days: 1
if-no-files-found: error
- name: Worker runtime tests
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
run: npm run test:workers
Expand Down
84 changes: 84 additions & 0 deletions .github/workflows/codecov-fork-upload.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
name: Codecov fork upload

# Fork PR CI runs without access to secrets, so ci.yml cannot upload their coverage to Codecov directly —
# it stashes the coverage report + PR metadata as the `fork-coverage` artifact. This workflow runs on
# `workflow_run` in the BASE-repo context (so it CAN read secrets.CODECOV_TOKEN) and performs the upload,
# keeping the codecov/patch gate enforced on fork PRs.
#
# Security: this is a privileged workflow processing a fork-produced artifact. It never checks out or runs
# fork code — it only downloads the artifact and passes validated metadata to codecov-action. The job runs
# ONLY for successful fork-PR CI runs, and every fork-controlled value is re-validated against a strict
# charset before use.
on:
workflow_run:
workflows: ["CI"]
types: [completed]

permissions:
contents: read
actions: read

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 workflow_run trigger enables privileged execution downstream of untrusted fork PRs

The workflow_run trigger runs in the base-repo context with access to secrets, indirectly reachable by fork PRs via the CI workflow artifact chain.

Consider replacing the upload with a GitHub App, or add a deployment environment with required reviewers for defense-in-depth.

AI prompt
Check if this security scanner issue is valid. If so, understand the root cause and fix it. If appropriate, update or add tests. Keep the change focused and preserve intended behavior.

<file name=".github/workflows/codecov-fork-upload.yml">
<violation number="1" location=".github/workflows/codecov-fork-upload.yml:19">
<priority>P1</priority>
<title>workflow_run trigger enables privileged execution downstream of untrusted fork PRs</title>
<evidence>The codecov-fork-upload.yml workflow uses a workflow_run trigger, which executes in the privileged base-repo context with access to secrets.CODECOV_TOKEN. This trigger is indirectly reachable by any fork PR that completes the CI workflow. While the workflow includes mitigations (no checkout, strict charset validation of artifact metadata, minimal permissions), the workflow_run pattern remains the highest-risk trigger in GitHub Actions. A bypass in the validation logic or in actions/download-artifact could allow attacker-controlled values to reach the privileged context.</evidence>
<recommendation>Consider replacing the privileged workflow_run upload with a GitHub App that listens for check_run or workflow_run webhook events and uploads to Codecov from outside Actions. If keeping workflow_run, add a deployment environment with required reviewers to the upload job for defense-in-depth.</recommendation>
</violation>
</file>


jobs:
upload:
if: >-
${{ github.event.workflow_run.conclusion == 'success'
&& github.event.workflow_run.event == 'pull_request'
&& github.event.workflow_run.head_repository.full_name != github.repository }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Download fork coverage artifact
id: download
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: fork-coverage
path: fork-coverage
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Read and validate coverage metadata
id: meta
run: |
f=fork-coverage/codecov-meta.env
if [ ! -f "$f" ]; then
echo "no fork-coverage artifact on this run — nothing to upload"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
pr="$(sed -n 's/^PR_NUMBER=//p' "$f" | head -1)"
sha="$(sed -n 's/^HEAD_SHA=//p' "$f" | head -1)"
branch="$(sed -n 's/^HEAD_BRANCH=//p' "$f" | head -1)"
# Reject anything that is not a plain PR number / hex sha / safe ref name — a fork controls these.
case "$pr" in '' | *[!0-9]*) echo "invalid PR number"; echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0 ;; esac
case "$sha" in '' | *[!0-9a-fA-F]*) echo "invalid head sha"; echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0 ;; esac
case "$branch" in '' | *[!A-Za-z0-9._/-]*) echo "invalid head branch"; echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0 ;; esac
{
echo "skip=false"
echo "pr=$pr"
echo "sha=$sha"
echo "branch=$branch"
} >> "$GITHUB_OUTPUT"
- name: Upload coverage to Codecov
if: ${{ steps.meta.outputs.skip == 'false' }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: fork-coverage/lcov.info
disable_search: true
override_branch: ${{ steps.meta.outputs.branch }}
override_commit: ${{ steps.meta.outputs.sha }}
override_pr: ${{ steps.meta.outputs.pr }}
# Same hard-gate semantics as the inline upload: a failed upload must not silently drop patch data.
fail_ci_if_error: true
- name: Upload Vitest results to Codecov
if: ${{ steps.meta.outputs.skip == 'false' && hashFiles('fork-coverage/vitest.xml') != '' }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: fork-coverage/vitest.xml
report_type: test_results
disable_search: true
override_branch: ${{ steps.meta.outputs.branch }}
override_commit: ${{ steps.meta.outputs.sha }}
override_pr: ${{ steps.meta.outputs.pr }}
fail_ci_if_error: false
Loading