SRE-904: Split token minting from the release run - #9192
Conversation
Port the hashintel/.github#99 pattern to release.yml: only a new mint-token job holds id-token: write and runs no repository dependency code; the release job receives the App token as ciphertext and holds no OIDC permission, so compromised build/publish dependencies can no longer mint fresh Vault-signed App JWTs. Also drop the unused id-token grant from deploy.yml's setup job, bump hashintel/.github pins to the merge commit of that PR, and pass the now-required RENOVATE_TOKEN_ENC_KEY through to the housekeeping workflow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HPkZcHmt5qmnGHtVB96d7
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
| run: | | ||
| : "${TOKEN_CIPHERTEXT:?}" "${ENC_KEY:?}" | ||
| token=$(printf '%s' "${TOKEN_CIPHERTEXT}" | openssl enc -d -aes-256-cbc -pbkdf2 -pass env:ENC_KEY -base64 -A) | ||
| echo "::add-mask::${token}" |
There was a problem hiding this comment.
Semgrep identified an issue in your code:
GitHub Actions workflow command masking can be bypassed by attackers who can inject stop-commands payload, exposing the decrypted token in plaintext logs.
More details about this
The decrypted token is being masked using echo "::add-mask::${token}" in the workflow, but this masking is fragile and can be bypassed by attackers. While add-mask prevents the plaintext token from appearing in logs under normal conditions, an attacker who gains write access to this workflow (by compromising a dependency or gaining access to the repository) can insert a stop-commands payload like echo "::stop-commands::stopMarker" before any steps that use this token. This will disable GitHub Actions workflow command processing, causing the GITHUB_TOKEN environment variable and the token output to be logged in plaintext, completely defeating the masking.
Concrete attack scenario:
- An attacker compromises a transitive npm dependency used in the
actions/checkoutor.github/actions/*steps - The compromised code executes early in the workflow and outputs
echo "::stop-commands::STOP" - This disables workflow command processing for all subsequent steps
- When later steps run
git pushorchangesets/actionusing the unmasked token fromsteps.app-token.outputs.token, the plaintext token appears directly in the logs - The attacker reads the logs and extracts the token to impersonate the release process or access npm
The root issue is relying solely on add-mask for secret protection when attackers can disable workflow commands entirely.
To resolve this comment:
✨ Commit fix suggestion
| echo "::add-mask::${token}" | |
| run: | | |
| : "${TOKEN_CIPHERTEXT:?}" "${ENC_KEY:?}" | |
| token=$(printf '%s' "${TOKEN_CIPHERTEXT}" | openssl enc -d -aes-256-cbc -pbkdf2 -pass env:ENC_KEY -base64 -A) | |
| echo "token=${token}" >>"${GITHUB_OUTPUT}" |
View step-by-step instructions
-
Remove the
echo "::add-mask::${token}"line so the workflow does not rely on theadd-maskcommand for protection. -
Keep the decrypted token out of stdout by only writing it to GitHub’s file-based channels, such as
"$GITHUB_OUTPUT"or"$GITHUB_ENV", and never printing it withechoby itself.
For this step, keepecho "token=${token}" >>"${GITHUB_OUTPUT}", becauseGITHUB_OUTPUTis a file and does not depend on workflow command parsing. -
Avoid any shell options or debug settings in this step that could print commands or variable values, such as
set -x, because that would expose${token}in logs even without theadd-maskline. -
Pass the token to later steps through the existing output reference, such as
token: ${{ steps.app-token.outputs.token }}andGITHUB_TOKEN: ${{ steps.app-token.outputs.token }}, instead of re-echoing or logging the plaintext token anywhere else. -
Alternatively, if you want to reduce the amount of time the plaintext token exists in a shell variable, write it directly to
"$GITHUB_OUTPUT"from the decrypt command flow and avoid a separatetokenreuse beyond that step.
💬 Ignore this finding
Reply with Semgrep commands to ignore this finding.
/fp <comment>for false positive/ar <comment>for acceptable risk/other <comment>for all other reasons
Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by unsafe-add-mask-workflow-command.
You can view more details about this finding in the Semgrep AppSec Platform.
There was a problem hiding this comment.
We're keeping the mask, intentionally. A ::stop-commands:: payload presupposes an attacker already running code inside this job — and that code can read the token straight out of its own environment, so masking was never the security boundary here. The boundary this PR actually adds is the job split: the job that runs dependency code no longer holds id-token: write, so a compromise is limited to the short-lived, repo-scoped installation token it was handed, with no path back to Vault to mint fresh ones.
Applying the suggested fix would make log hygiene strictly worse: without add-mask, the decrypted value is never registered for redaction, so any accidental print — including ACTIONS_STEP_DEBUG expression evaluation of steps.app-token.outputs.token — lands in plaintext logs. Values written to GITHUB_OUTPUT are not auto-masked; the mask is the only thing keeping them out of debug output.
This mirrors the same reviewed pattern in hashintel/.github#99.
Leaving triage (/fp / /ar) to the reviewers.
Generated by Claude Code
| run: | | ||
| : "${TOKEN_CIPHERTEXT:?}" "${ENC_KEY:?}" | ||
| token=$(printf '%s' "${TOKEN_CIPHERTEXT}" | openssl enc -d -aes-256-cbc -pbkdf2 -pass env:ENC_KEY -base64 -A) | ||
| echo "::add-mask::${token}" |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #9192 +/- ##
=======================================
Coverage 59.63% 59.63%
=======================================
Files 1420 1420
Lines 138767 138767
Branches 6555 6555
=======================================
Hits 82754 82754
Misses 54949 54949
Partials 1064 1064 Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
PR SummaryMedium Risk Overview Deploy setup swaps a pointless Shared workflow pins move from Reviewed by Cursor Bugbot for commit f720252. Bugbot is set up for automated code reviews on this repo. Configure here. |
Benchmark results
|
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| resolve_policies_for_actor | user: empty, selectivity: high, policies: 2002 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: medium, policies: 1002 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: high, policies: 3314 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: medium, policies: 1527 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: high, policies: 2078 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: medium, policies: 1033 | Flame Graph |
policy_resolution_medium
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| resolve_policies_for_actor | user: empty, selectivity: high, policies: 102 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: medium, policies: 52 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: high, policies: 269 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: medium, policies: 108 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: high, policies: 133 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: medium, policies: 63 | Flame Graph |
policy_resolution_none
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| resolve_policies_for_actor | user: empty, selectivity: high, policies: 2 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: medium, policies: 2 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: high, policies: 8 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: medium, policies: 3 | Flame Graph |
policy_resolution_small
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| resolve_policies_for_actor | user: empty, selectivity: high, policies: 52 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: medium, policies: 26 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: high, policies: 94 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: medium, policies: 27 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: high, policies: 66 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: medium, policies: 29 | Flame Graph |
read_scaling_complete
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| entity_by_id;one_depth | 1 entities | Flame Graph | |
| entity_by_id;one_depth | 10 entities | Flame Graph | |
| entity_by_id;one_depth | 25 entities | Flame Graph | |
| entity_by_id;one_depth | 5 entities | Flame Graph | |
| entity_by_id;one_depth | 50 entities | Flame Graph | |
| entity_by_id;two_depth | 1 entities | Flame Graph | |
| entity_by_id;two_depth | 10 entities | Flame Graph | |
| entity_by_id;two_depth | 25 entities | Flame Graph | |
| entity_by_id;two_depth | 5 entities | Flame Graph | |
| entity_by_id;two_depth | 50 entities | Flame Graph | |
| entity_by_id;zero_depth | 1 entities | Flame Graph | |
| entity_by_id;zero_depth | 10 entities | Flame Graph | |
| entity_by_id;zero_depth | 25 entities | Flame Graph | |
| entity_by_id;zero_depth | 5 entities | Flame Graph | |
| entity_by_id;zero_depth | 50 entities | Flame Graph |
read_scaling_linkless
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| entity_by_id | 1 entities | Flame Graph | |
| entity_by_id | 10 entities | Flame Graph | |
| entity_by_id | 100 entities | Flame Graph | |
| entity_by_id | 1000 entities | Flame Graph | |
| entity_by_id | 10000 entities | Flame Graph |
representative_read_entity
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/block/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/book/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/building/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/organization/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/page/v/2
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/person/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/playlist/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/song/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/uk-address/v/1
|
Flame Graph |
representative_read_entity_type
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| get_entity_type_by_id | Account ID: bf5a9ef5-dc3b-43cf-a291-6210c0321eba
|
Flame Graph |
representative_read_multiple_entities
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| entity_by_property | traversal_paths=0 | 0 | |
| entity_by_property | traversal_paths=255 | 1,resolve_depths=inherit:1;values:255;properties:255;links:127;link_dests:126;type:true | |
| entity_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:0;links:0;link_dests:0;type:false | |
| entity_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:0;links:1;link_dests:0;type:true | |
| entity_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:2;links:1;link_dests:0;type:true | |
| entity_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:2;properties:2;links:1;link_dests:0;type:true | |
| link_by_source_by_property | traversal_paths=0 | 0 | |
| link_by_source_by_property | traversal_paths=255 | 1,resolve_depths=inherit:1;values:255;properties:255;links:127;link_dests:126;type:true | |
| link_by_source_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:0;links:0;link_dests:0;type:false | |
| link_by_source_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:0;links:1;link_dests:0;type:true | |
| link_by_source_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:2;links:1;link_dests:0;type:true | |
| link_by_source_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:2;properties:2;links:1;link_dests:0;type:true |
scenarios
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| full_test | query-limited | Flame Graph | |
| full_test | query-unlimited | Flame Graph | |
| linked_queries | query-limited | Flame Graph | |
| linked_queries | query-unlimited | Flame Graph |
| vault_address: ${{ vars.VAULT_STAGE_ADDR }} | ||
| cf_access_client_id: ${{ vars.CF_ACCESS_STAGE_CLIENT_ID }} | ||
| cf_access_client_secret: ${{ secrets.CF_ACCESS_STAGE_CLIENT_SECRET }} | ||
| sccache: false |
There was a problem hiding this comment.
Running turbo-command may invoke Rust as well. Let's keep it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HPkZcHmt5qmnGHtVB96d7
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit f720252. Configure here.
| environment: main | ||
| permissions: | ||
| contents: read | ||
| id-token: write |
There was a problem hiding this comment.
Dead OIDC grant on release
High Severity
id-token: write is restored on release while install-tools still lacks vault_address / cf_access_*, so sccache never runs (cf_access_client_secret != '' gate). Compromised dependency code can mint fresh OIDC tokens again, without any legitimate OIDC consumer in the job.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit f720252. Configure here.


Requested by Tim Diekmann · Slack thread
🌟 What is the purpose of this PR?
Ports the token-minting split from hashintel/.github#99 to this repo's release workflow, and removes
id-token: writefrom a job that never consumes it. This is a supply-chain containment change: it narrows what compromised dependency code running in CI can reach.Before: the single
releasejob heldid-token: writewhile running the full dependency graph (mise/Rust toolchain install,yarn install, turbo builds,yarn changeset publishand every module those load). Any compromised build-time dependency in that job could not only read the HASH Release App installation token andNPM_TOKENfrom its own environment, but also use the job-wideACTIONS_ID_TOKEN_REQUEST_*credentials to mint fresh OIDC tokens and authenticate to any Vault role whose claim bindings the run satisfies — at minimum the sccache role, whose R2 credentials allow poisoning the shared compile cache that feeds every Rust build. Similarly,deploy.yml'ssetupjob grantedid-token: writeto a job that runsturbo query(dependency code) but consumes no OIDC at all.After: only a new
mint-tokenjob holdsid-token: write, and it runs nothing but the pinnedgithub-app-tokencomposite action — no repository dependency code executes there. Thereleasejob holds no OIDC permission: compromised dependency code there gets at most the hour-long installation token it is handed (which the split intentionally does not protect — the publish needs it), and can no longer sign fresh App JWTs in Vault or fetch sccache credentials. The dead grant indeploy.yml'ssetupjob is gone entirely.🔗 Related links
b7a5d7f)id-token+dependency-code combinations this assessment flagged🚫 Blocked by
RELEASE_TOKEN_ENC_KEYmust exist inhashintel/hashbefore merge (value:openssl rand -base64 32; repo-level secret). Without it the mint job fails at its: "${ENC_KEY:?}"guard on the next push tomain.RENOVATE_TOKEN_ENC_KEY— the bumpedhousekeeping-dependencies.ymldeclares itrequired: true. Already created inhashintel/hashas part of the SRE-904: Split token minting from the Renovate run .github#99 rollout, so the passthrough works immediately.🔍 What does this change?
.github/workflows/release.yml— split into two jobs:mint-token(environment: mainkept — theci-hash-releaseVault role binds the OIDCenvironmentclaim):contents: read+id-token: writeonly; runs the existing pinnedgithub-app-tokenaction unchanged, then encrypts the token withopenssl enc -aes-256-cbc -pbkdf2 -salt -pass env:ENC_KEY -base64 -Akeyed by the newRELEASE_TOKEN_ENC_KEYsecret. Only ciphertext leaves the job (GitHub drops masked values from job outputs, so plaintext could not cross anyway).: "${VAR:?}"guards fail loud on missing inputs. The openssl encrypt/decrypt round-trip was verified against OpenSSL 3.0.13 (the ubuntu-latest version).release: noid-token; decrypts as its first step and::add-mask::s the plaintext before anything else runs, then the existing checkout/install/changesets steps unchanged — exceptinstall-toolsnow getssccache: false(the sccache credential fetch was the only remaining OIDC consumer in this job). The Rust toolchain stays: publishing@blockprotocol/type-systembuilds the wasm artifact from the Rust crate (build:wasmvia wasm-pack;rust/pkg/is gitignored, so it cannot come from the checkout), sorust: falsewas not an option. The cost is uncached Rust compiles in this job.environment: mainis kept on this job too so environment-scoped secrets remain reachable.NPM_TOKENhandling and the changesets config are untouched..github/workflows/deploy.yml—setupjob:id-token: write→contents: read. The job passesrust: falsetoinstall-tools, and the sccache credential fetch (gated on Rust) is the only OIDC consumer in that action — nothing in the job can use the grant, so it only widened what its dependency code (turbo query) could mint..github/workflows/{preflight,preflight-todo-comments,housekeeping}.yml+release.yml— bump everyhashintel/.githubpin from8c1a3b1tob7a5d7f651c1d6a862d5dd97aa6164125bedb6c5, the merge commit of SRE-904: Split token minting from the Renovate run .github#99. The bump crosses only Include messages within json_state output #98 (Renovate 43.288.0) and Fix behavior execution for Javascript runners #99 (the mint split); all referenced reusable workflows and thegithub-app-tokenaction exist unchanged at the new pin..github/workflows/housekeeping.yml— passRENOVATE_TOKEN_ENC_KEYthrough to the reusable workflow, which now requires it for the same encrypted job-boundary handoff.Deliberately not changed:
canary-release.ymlkeeps itsid-token: write. Its only OIDC consumer is the sccache credential fetch, but the canary publish runs the sameyarn changeset publishpath and therefore needs the Rust toolchain for the@blockprotocol/type-systemwasm build; dropping sccache there trades a real (uncached full Rust builds on a manually-dispatched workflow) for a marginal gain — the stealable asset is the stage-Vault sccache role, not an App token. Can be revisited if the cache-poisoning angle is judged to outweigh that.publish-blocks-to-preview.ymlwas in scope (two sccache-onlyid-tokengrants alongsideyarn build+ unpinnednpx blockprotocol@latest), but SRE-908: Delete the block-publishing workflows #9187 deleted both block-publishing workflows before this PR — nothing left to fix.Pre-Merge Checklist 🚀
🚢 Has this modified a publishable library?
This PR:
📜 Does this require a change to the docs?
The changes in this PR:
🕸️ Does this require a change to the Turbo Graph?
The changes in this PR:
releasejob can still read it. What the split removes is the ability to mint: fresh App JWTs signed in Vault, sccache R2 credentials, or any other role reachable with a fresh OIDC token.NPM_TOKEN, thereleasejob will needid-token: writeback for the npm exchange — that use is inseparable from the publish step. The Vault/App-token mint should stay inmint-tokenregardless.🐾 Next steps
deploy.yml'ssourcemapsjob (Sentry token mint + dependency code); lower value, tracked separately.🛡 What tests cover this?
actionlinton all five changed files; the only findings are the pre-existingqueue:keys actionlint's schema does not know). Release workflows are only exercised by a real push tomain.❓ How to test this?
mainpush:mint-tokenshould complete in seconds and output only ciphertext;releaseshould decrypt, mask, and proceed through changesets exactly as before.releasejob shows noid-tokenin its permissions block and no sccache install.RENOVATE_TOKEN_ENC_KEYsecret already exists; the requirement would fail fast at dispatch if it were missing).📹 Demo
Not applicable (CI-only change).