Skip to content

ci: harden metadata release and triage workflow permissions - #458

Merged
twcclegg merged 1 commit into
mainfrom
ci/harden-release-and-triage-workflows
Sep 6, 2026
Merged

ci: harden metadata release and triage workflow permissions#458
twcclegg merged 1 commit into
mainfrom
ci/harden-release-and-triage-workflows

Conversation

@twcclegg

@twcclegg twcclegg commented Sep 5, 2026

Copy link
Copy Markdown
Owner

1. Script injection in finalize_metadata_release.yml (Scorecard: Dangerous-Workflow, critical)

The release step interpolated event data straight into a run: body:

run: |
  bash lib/finalize-metadata-release.sh \
    "${{ github.event.pull_request.head.ref }}" \
    "${{ github.event.pull_request.merge_commit_sha }}"

head.ref is attacker-supplied text, and the expression engine substitutes it before bash parses the line — so a branch name containing shell metacharacters executes as code, in a job holding contents: write and actions: write.

Both values now reach the step through env: and are referenced as quoted shell variables. The script receives the same two positional arguments in the same order.

The existing if: gates (merged PR, base main, metadata-update/* prefix, author libphonenumber-csharp-bot) made this hard to reach but did not remove it. They are unchanged.

2. Over-broad token permissions in triage_metadata_issues.yml (Scorecard: Token-Permissions, high)

The top-level block granted contents/issues/pull-requests/copilot-requests: write to every job in the file. Top level is now contents: read, with the four writes moved onto the single triage job that needs them, each commented with the step that requires it. No future job added to this file silently inherits write access.

Deliberately not changed: the two finalize_metadata_release.yml job permissions

Scorecard also flags contents: write (line 32) and actions: write (line 33) on that job. Both are the minimum for the flow, verified against lib/finalize-metadata-release.sh, lib/github-release-helpers.sh and publish_nuget.yml:

  • contents: writecreateRelease POSTs /repos/.../releases with target_commitish, which creates the vX.Y.Z tag as well as the release. There is no finer-grained release scope, and contents: read cannot create a ref.
  • actions: writedispatchPublish POSTs to /actions/workflows/publish_nuget.yml/dispatches, which requires it. The dispatch cannot be dropped: GitHub suppresses push events raised by GITHUB_TOKEN, so the tag created above will not fire publish_nuget.yml's push: tags: ['v*'] trigger on its own — which is what the # github suppresses push events from GITHUB_TOKEN comment in the helper records.

Both permissions now carry a comment naming the exact API call that needs them. Suggest dismissing those two alerts as used-as-designed rather than weakening the release path.

Verification

  • Both files parse as YAML.
  • Interpolation scan across every run: body in both files: the only remaining ${{ }} is ${{ runner.temp }}, which is runner-provided, not event-controlled. (actions/github-script script: blocks still use ${{ toJSON(...) }}, the correct JS-context-safe pattern — not a shell body.)
  • Effective permissions after the change — finalize: top contents: read, job contents: write, actions: write; triage: top contents: read, job contents/issues/pull-requests/copilot-requests: write.

finalize_metadata_release.yml interpolated
${{ github.event.pull_request.head.ref }} and
${{ github.event.pull_request.merge_commit_sha }} directly into the run
block's bash command line. The expression engine substitutes those before
the shell sees the script, so a branch name containing shell
metacharacters - backticks, $(...), a semicolon - would be executed as
code by the runner, with the job's contents: write and actions: write
token in the environment. The existing `pull_request: closed` trigger and
the user.login == 'libphonenumber-csharp-bot' / metadata-update/* branch
gate make that hard to reach, but they do not remove the injection, so
both values are now passed to the step via env: and referenced as quoted
"$HEAD_REF" / "$MERGE_SHA". The script still gets the same two positional
arguments in the same order.

The job keeps contents: write and actions: write, now with a comment
naming what needs each: createRelease POSTs /repos/.../releases, which
creates the vX.Y.Z tag as well as the release entry (contents), and
dispatchPublish POSTs the publish_nuget.yml workflow_dispatch (actions).
github suppresses push events raised by GITHUB_TOKEN, so the tag alone
cannot start the publish run and the dispatch cannot be dropped.

triage_metadata_issues.yml granted contents/issues/pull-requests/
copilot-requests write at the top level, so every job in the file - and
any job added later - inherited them. The top level is now contents: read
and the triage job requests those four explicitly, each with a comment
naming the step that needs it.
@codecov

codecov Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.51%. Comparing base (8ad7fa2) to head (9d71888).

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #458   +/-   ##
=======================================
  Coverage   87.51%   87.51%           
=======================================
  Files          43       43           
  Lines        3886     3886           
  Branches      991      991           
=======================================
  Hits         3401     3401           
  Misses        280      280           
  Partials      205      205           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant