From 92e337fa123f8418790a1854a9b78c18826c7a17 Mon Sep 17 00:00:00 2001 From: j7an Date: Thu, 25 Jun 2026 21:05:03 -0700 Subject: [PATCH 1/3] feat(safety): add reusable non-bot dependency gate --- .../dependency-safety-non-bot-gate.yml | 33 +++++++ tests/non-bot-gate-contract.bats | 86 +++++++++++++++++++ tests/non-bot-gate-runtime.bats | 85 ++++++++++++++++++ 3 files changed, 204 insertions(+) create mode 100644 .github/workflows/dependency-safety-non-bot-gate.yml create mode 100644 tests/non-bot-gate-contract.bats create mode 100644 tests/non-bot-gate-runtime.bats diff --git a/.github/workflows/dependency-safety-non-bot-gate.yml b/.github/workflows/dependency-safety-non-bot-gate.yml new file mode 100644 index 0000000..b5d4114 --- /dev/null +++ b/.github/workflows/dependency-safety-non-bot-gate.yml @@ -0,0 +1,33 @@ +name: Dependency Safety Non-Bot Gate + +on: + workflow_call: + +permissions: {} + +jobs: + gate: + # Complements a scanner caller gated to Dependabot only. Use the PR author + # field from the pull_request payload; github.actor can change on + # synchronize events and must not drive this partition. + if: github.event.pull_request.user.login != 'dependabot[bot]' + runs-on: ubuntu-latest + permissions: + statuses: write + steps: + - name: Post dependency-safety gate status + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + if [ -z "${HEAD_SHA:-}" ]; then + echo "::error::dependency-safety non-bot gate requires a pull_request_target caller" + exit 1 + fi + + gh api "repos/${GH_REPO}/statuses/${HEAD_SHA}" \ + -f state=success \ + -f context="dependency-safety / gate" \ + -f description="Non-bot PR: dependency-safety scan not required" \ + -f target_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" diff --git a/tests/non-bot-gate-contract.bats b/tests/non-bot-gate-contract.bats new file mode 100644 index 0000000..e06ce60 --- /dev/null +++ b/tests/non-bot-gate-contract.bats @@ -0,0 +1,86 @@ +#!/usr/bin/env bats +# non-bot-gate-contract.bats - static contract tests for the reusable +# dependency-safety non-bot gate workflow. + +YAML=".github/workflows/dependency-safety-non-bot-gate.yml" + +extract_on_block() { + awk ' + /^on:$/ { flag=1; print; next } + flag && /^[^[:space:]][^:]*:/ { exit } + flag { print } + ' "$YAML" +} + +extract_gate_job_block() { + awk ' + /^ gate:$/ { flag=1; print; next } + flag && /^ [A-Za-z0-9_-]+:/ { exit } + flag { print } + ' "$YAML" +} + +extract_job_permissions_block() { + extract_gate_job_block | awk ' + /^ permissions:$/ { flag=1; next } + flag && /^ [A-Za-z0-9_-]+:/ { exit } + flag { print } + ' +} + +extract_run_block() { + awk ' + /^ - name: Post dependency-safety gate status$/ { in_step = 1 } + in_step && /^ run: \|$/ { in_run = 1; next } + in_run && /^ - name: / { exit } + in_run { print } + ' "$YAML" | sed -E 's/^ //' +} + +non_comment_content() { + sed '/^[[:space:]]*#/d' "$YAML" +} + +@test "workflow is reusable only: workflow_call trigger and no pull_request_target trigger" { + block=$(extract_on_block) + [[ "$block" == *"workflow_call:"* ]] + [[ "$block" != *"pull_request_target:"* ]] +} + +@test "non-comment workflow logic does not use github.actor" { + if non_comment_content | grep -q "github.actor"; then + echo "github.actor must not be used as workflow logic" + return 1 + fi +} + +@test "gate job uses pull_request.user.login complement of scanner caller" { + grep -qF "if: github.event.pull_request.user.login != 'dependabot[bot]'" "$YAML" +} + +@test "gate job requests exactly statuses: write" { + [ "$(extract_job_permissions_block)" = " statuses: write" ] +} + +@test "status post uses canonical context, description, and caller run target_url" { + grep -qF -- '-f context="dependency-safety / gate"' "$YAML" + grep -qF -- '-f description="Non-bot PR: dependency-safety scan not required"' "$YAML" + grep -qF -- '-f target_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"' "$YAML" +} + +@test "workflow has no checkout and no third-party action steps" { + if grep -qF "actions/checkout" "$YAML"; then + echo "checkout is forbidden in the status-only gate" + return 1 + fi + + if grep -Eq '^[[:space:]]+-[[:space:]]+uses:' "$YAML"; then + echo "third-party action steps are forbidden in the status-only gate" + return 1 + fi +} + +@test "run block does not interpolate PR event context directly" { + block=$(extract_run_block) + [[ "$block" != *'${{ github.event.pull_request'* ]] +} diff --git a/tests/non-bot-gate-runtime.bats b/tests/non-bot-gate-runtime.bats new file mode 100644 index 0000000..8051003 --- /dev/null +++ b/tests/non-bot-gate-runtime.bats @@ -0,0 +1,85 @@ +#!/usr/bin/env bats +# non-bot-gate-runtime.bats - execute the reusable non-bot gate run block +# against a stubbed gh CLI. The trusted pull_request_target path must fail +# loud on every gh error; it has no 403 soft-fail path. + +YAML=".github/workflows/dependency-safety-non-bot-gate.yml" + +setup() { + TEST_TMP=$(mktemp -d) + STUB_BIN="$TEST_TMP/bin" + mkdir -p "$STUB_BIN" + export GH_REPO="octo/example" + export HEAD_SHA="deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + export GITHUB_SERVER_URL="https://github.com" + export GITHUB_REPOSITORY="octo/example" + export GITHUB_RUN_ID="123" +} + +teardown() { + rm -rf "$TEST_TMP" +} + +extract_gate_block() { + awk ' + /^ - name: Post dependency-safety gate status$/ { in_step = 1 } + in_step && /^ run: \|$/ { in_run = 1; next } + in_run && /^ - name: / { exit } + in_run { print } + ' "$YAML" | sed -E 's/^ //' +} + +write_gh_stub() { + printf '%s' "$1" > "$TEST_TMP/gh_msg" + cat > "$STUB_BIN/gh" < "$TEST_TMP/gh_args" +cat "$TEST_TMP/gh_msg" >&2 +if [ -s "$TEST_TMP/gh_msg" ]; then + echo >&2 +fi +exit $2 +EOF + chmod +x "$STUB_BIN/gh" +} + +run_gate_block() { + local f="$TEST_TMP/gate.sh" + { echo 'set -eo pipefail'; extract_gate_block; } > "$f" + PATH="$STUB_BIN:$PATH" run bash "$f" +} + +@test "status post succeeds with populated HEAD_SHA" { + write_gh_stub "" 0 + run_gate_block + [ "$status" -eq 0 ] + grep -qx "api" "$TEST_TMP/gh_args" + grep -qx "repos/octo/example/statuses/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" "$TEST_TMP/gh_args" + grep -qx "state=success" "$TEST_TMP/gh_args" + grep -qx "context=dependency-safety / gate" "$TEST_TMP/gh_args" + grep -qx "description=Non-bot PR: dependency-safety scan not required" "$TEST_TMP/gh_args" + grep -qx "target_url=https://github.com/octo/example/actions/runs/123" "$TEST_TMP/gh_args" +} + +@test "empty HEAD_SHA fails before calling gh" { + export HEAD_SHA="" + write_gh_stub "" 0 + run_gate_block + [ "$status" -ne 0 ] + [[ "$output" == *"::error::dependency-safety non-bot gate requires a pull_request_target caller"* ]] + [ ! -s "$TEST_TMP/gh_args" ] +} + +@test "403 from gh fails loud" { + write_gh_stub '{"message":"Resource not accessible by integration","status":"403","documentation_url":"https://docs.github.com/rest"}' 1 + run_gate_block + [ "$status" -ne 0 ] + [[ "$output" == *"Resource not accessible by integration"* ]] +} + +@test "generic gh failure fails loud" { + write_gh_stub "gh: HTTP 500 Internal Server Error" 1 + run_gate_block + [ "$status" -ne 0 ] + [[ "$output" == *"HTTP 500"* ]] +} From a204a2bafea8dd493f2a067169c870fb6469f7a3 Mon Sep 17 00:00:00 2001 From: j7an Date: Thu, 25 Jun 2026 21:09:44 -0700 Subject: [PATCH 2/3] docs(safety): document non-bot gate pairing --- .github/workflows/README.md | 46 ++++++++++++ README.md | 140 ++++++++++++++++++++++++------------ 2 files changed, 140 insertions(+), 46 deletions(-) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 31873f8..d5c0dcb 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -4,6 +4,52 @@ This directory hosts reusable workflows under `j7an/shared-workflows`. Consumers > **Note:** `@v3` and `@v2` continue to work at their last-released revisions, but receive no further updates. See the root README's "v3 → v4 migration" section. +## `dependency-safety-non-bot-gate.yml` + +Posts the required `dependency-safety / gate` commit status for pull requests +whose author is not `dependabot[bot]`. This is a status-only companion for +repos whose real `dependency-safety.yml` scanner caller is gated to +Dependabot-only. + +The reusable workflow is `workflow_call`-only. The consumer keeps the trusted +`pull_request_target` trigger in a tiny local wrapper: + +```yaml +name: Dependency Safety Non-Bot Gate + +on: + pull_request_target: # zizmor: ignore[dangerous-triggers] status-only path; never checks out or runs PR code + types: [opened, synchronize, reopened] + branches: [main] # include every branch where dependency-safety / gate is required + +permissions: {} + +concurrency: + group: dep-safety-gate-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + gate: + permissions: + statuses: write + uses: j7an/shared-workflows/.github/workflows/dependency-safety-non-bot-gate.yml@v4 +``` + +Pair it with a scanner caller that uses the complementary condition: + +```yaml +jobs: + safety: + if: github.event.pull_request.user.login == 'dependabot[bot]' + uses: j7an/shared-workflows/.github/workflows/dependency-safety.yml@v4 + secrets: inherit +``` + +The wrapper must not check out code, pass `secrets: inherit`, install +dependencies, or run PR-authored files. The wrapper grants `statuses: write`; +the reusable workflow requests `statuses: write`; the status is posted to +`github.event.pull_request.head.sha` with context `dependency-safety / gate`. + ## `tag-release.yml` Computes the next semver tag from Conventional Commits since the last tag, optionally bumps version files, and pushes the new tag (which typically triggers a downstream release workflow). diff --git a/README.md b/README.md index 39eb397..9c11ffa 100644 --- a/README.md +++ b/README.md @@ -172,55 +172,106 @@ Reconciliation is authoritative when the scan succeeds. On the `error` path, lab ## Fork PRs and the required gate -`dependency-safety.yml` is a **Dependabot-automation** gate. It scans -`dependabot[bot]` PRs and passes every other actor through with a neutral -`success`. Human PRs from a branch **in your repo** also get that `success` -write, because their token can write commit statuses. - -**External fork PRs are different.** For a `pull_request` triggered from a fork, -GitHub gives `GITHUB_TOKEN` a read-only token on your repo by default — -`statuses` included — *even when the caller workflow declares* `statuses: write` -([GitHub docs][fork-perms]). The one exception is a repo admin enabling -**Send write tokens to workflows from pull requests** in the repository's -Actions settings; with that off (the default), the fork run cannot create the -`dependency-safety / gate` commit status. - -**What the workflow does:** on a fork PR it attempts the status write, detects -the read-only denial (`HTTP 403 Resource not accessible by integration`), logs a -`notice` and a job-summary line, and **finishes green**. It does *not* present -this as a dependency-safety scan failure. Genuine errors — and the real -Dependabot scan/status path — still fail loudly. - -**The unavoidable gap:** a green job still cannot *post* the status from a fork -run. If you require `dependency-safety / gate` as a status check, fork PRs will -sit with that check **unsatisfied** until a *trusted* path posts it. The -reusable workflow cannot close this gap from the fork run — add a companion in -your repo. - -**Recommended safe companion** — a status-only `pull_request_target` job that -posts the gate for fork PRs and **never checks out or runs PR-authored code**: +`dependency-safety.yml` is a **Dependabot-automation** gate. Repos that make +`dependency-safety / gate` required need exactly one trusted writer for every +pull request head SHA. The correct companion depends on how your scanner caller +is gated. + +**Recommended matched pair: Dependabot-gated scanner plus non-bot gate.** This +is the cleanest shape for repos that require `dependency-safety / gate` on all +PRs: Dependabot PRs run the real scanner, and every other PR gets a +status-only gate. + +```yaml +# .github/workflows/dependency-safety.yml +name: Dependency Safety + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: write + pull-requests: write + statuses: write + issues: write + +concurrency: + group: dependency-safety-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + safety: + # Real scan for Dependabot only; non-Dependabot PRs are handled by + # dependency-safety-non-bot-gate.yml. Keep this field paired with the + # non-bot gate's complementary condition. + if: github.event.pull_request.user.login == 'dependabot[bot]' + uses: j7an/shared-workflows/.github/workflows/dependency-safety.yml@v4 + secrets: inherit +``` + +```yaml +# .github/workflows/dependency-safety-non-bot-gate.yml +name: Dependency Safety Non-Bot Gate + +on: + pull_request_target: # zizmor: ignore[dangerous-triggers] status-only path; never checks out or runs PR code + types: [opened, synchronize, reopened] + branches: [main] # include every branch where dependency-safety / gate is required + +permissions: {} + +concurrency: + group: dep-safety-gate-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + gate: + permissions: + statuses: write + uses: j7an/shared-workflows/.github/workflows/dependency-safety-non-bot-gate.yml@v4 +``` + +This non-bot gate is the companion to a Dependabot-gated scanner caller. If +your scanner caller runs ungated, use the fork-only pattern below instead. Do +not mix a Dependabot-gated scanner with a fork-only gate: same-repo human PRs +would have no writer for the required status. The non-bot wrapper's `branches:` +filter must cover every branch where a ruleset or branch protection rule +requires `dependency-safety / gate`. + +**Why the wrapper is safe:** `pull_request_target` runs in your repo's context +with a write token, which is exactly what is needed to post the status, and it +is safe here only because the wrapper delegates to a status-only reusable +workflow. The reusable workflow performs no checkout, uses no third-party +actions, runs no dependency install/build/test, executes no PR-authored files, +requests only `statuses: write`, uses only the automatic `github.token`, and +passes PR-derived values into shell through `env:`. Do not add +`secrets: inherit` to the non-bot gate wrapper. + +**Alternative: ungated scanner plus fork-only gate.** If your scanner caller +runs on every `pull_request`, same-repo non-bot PRs are handled by the scanner's +own pass-through branch. In that architecture, add only a fork companion: ```yaml -# .github/workflows/fork-pr-gate.yml (your repo — adapt as needed) +# .github/workflows/fork-pr-gate.yml name: Fork PR dependency-safety gate on: pull_request_target: types: [opened, synchronize, reopened] permissions: - statuses: write # nothing else + statuses: write jobs: gate: - # cross-repo (fork) PRs only — same-repo PRs are handled by the reusable workflow + # cross-repo fork PRs only; same-repo PRs are handled by the scanner workflow if: github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id runs-on: ubuntu-latest steps: - # NO checkout. This job never fetches or runs PR-authored code. - name: Post neutral gate status env: GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} + GH_REPO: ${{ github.repository }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | gh api "repos/${GH_REPO}/statuses/${HEAD_SHA}" \ -f state="success" \ @@ -229,19 +280,16 @@ jobs: -f target_url="${RUN_URL}" ``` -**Why this is safe:** `pull_request_target` runs in your repo's context with a -write token — exactly what's needed to post the status — and it is safe here -*only because* the job has no `checkout`, runs no PR code, requests -`statuses: write` and nothing else, and reads every PR-derived value through -`env:` (never interpolated with `${{ … }}` inside `run:`). This is the -constrained, status-only use of `pull_request_target` — **not** the broad -"build/test the PR with elevated permissions" pattern, which would expose your -secrets to fork-authored code. If a blanket `success` is too permissive for -your repo, post `pending` instead and require a maintainer to flip the status -after review. If you run [Zizmor](#security-analysis-zizmor) on your repo, it -will flag this file for its `pull_request_target` trigger — that finding is -expected here; the constraints above (no `checkout`, `statuses: write` only, -`env:` indirection) are exactly the safe envelope to verify. +External fork PRs get a read-only `GITHUB_TOKEN` under `pull_request` by +default, even when the caller declares `statuses: write`, unless a repo admin +enables **Send write tokens to workflows from pull requests** ([docs][fork-perms]). +The trusted `pull_request_target` wrapper closes that required-status gap +without running untrusted PR code. + +If you run [Zizmor](#security-analysis-zizmor), it will flag the wrapper's +`pull_request_target` trigger. That finding is expected for this constrained, +status-only pattern; verify the no-checkout, no-PR-code, `statuses: write`-only +envelope instead of suppressing the architectural review. [fork-perms]: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#changing-the-permissions-in-a-forked-repository From fd48e5233fe3de174867937412644e8e94fc2544 Mon Sep 17 00:00:00 2001 From: j7an Date: Thu, 25 Jun 2026 21:53:49 -0700 Subject: [PATCH 3/3] chore(safety): document non-bot gate job metadata --- .github/workflows/dependency-safety-non-bot-gate.yml | 2 ++ tests/non-bot-gate-contract.bats | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dependency-safety-non-bot-gate.yml b/.github/workflows/dependency-safety-non-bot-gate.yml index b5d4114..cfc3caa 100644 --- a/.github/workflows/dependency-safety-non-bot-gate.yml +++ b/.github/workflows/dependency-safety-non-bot-gate.yml @@ -7,12 +7,14 @@ permissions: {} jobs: gate: + name: Post dependency-safety gate # Complements a scanner caller gated to Dependabot only. Use the PR author # field from the pull_request payload; github.actor can change on # synchronize events and must not drive this partition. if: github.event.pull_request.user.login != 'dependabot[bot]' runs-on: ubuntu-latest permissions: + # Required to create the dependency-safety / gate commit status. statuses: write steps: - name: Post dependency-safety gate status diff --git a/tests/non-bot-gate-contract.bats b/tests/non-bot-gate-contract.bats index e06ce60..9e341da 100644 --- a/tests/non-bot-gate-contract.bats +++ b/tests/non-bot-gate-contract.bats @@ -59,7 +59,7 @@ non_comment_content() { } @test "gate job requests exactly statuses: write" { - [ "$(extract_job_permissions_block)" = " statuses: write" ] + [ "$(extract_job_permissions_block | sed '/^[[:space:]]*#/d')" = " statuses: write" ] } @test "status post uses canonical context, description, and caller run target_url" {