At 01:20 UTC on 7 October 2026, a malicious commit landed on the main branch of tensorlakeai/tensorlake under a maintainer's name. StepSecurity reports that seven more followed and "none of them went through a pull request." At 01:12 UTC on 8 October, the repository's own release workflow published tensorlake 0.5.144 to npm with a provenance attestation, which, in StepSecurity's words, "says where a package was built. It doesn't say the code is safe."
Three days earlier, @subql/common 5.8.3 shipped from a release commit on a branch that existed for minutes. In both cases a push credential was enough to publish. This guide makes it not enough.
What's affected
Repositories where any of these is true:
- The default branch accepts direct pushes, so no review sits between a stolen token and the code that gets built.
- Anyone with write access can create or move release tags.
- The publish job runs from any branch, tag or manual trigger, and its npm credential is reachable from all of them.
- Workflow files can change without review by the people who own releases.
npm's own trusted publishing page recommends two of the fixes below: "Use deployment environments to add approval requirements" and "Enable tag protection rules to control who can create release tags."
How to check if you're exposed
1. See which rules protect the default branch. This returns every active ruleset rule for it, from any level.
REPO=your-org/your-package
gh api "repos/$REPO/rules/branches/main" --jq '.[].type'
gh ruleset list -R "$REPO"
No pull_request in the output means a push token can change what you release.
2. Check tag rules. Run gh ruleset view RULESET_ID -R "$REPO" on each ruleset and look for one with a tag target covering your release tags.
3. Read the publish job. Find the trigger and whether the job names an environment:
gh api -H "Accept: application/vnd.github.raw+json" \
"repos/$REPO/contents/.github/workflows/release.yml" | grep -nE 'on:|tags:|branches:|workflow_dispatch|environment:'
4. Inspect that environment.
gh api "repos/$REPO/environments/npm-publish" \
--jq '[([.protection_rules[]?.type] | join(",")), (.deployment_branch_policy | tostring)] | @tsv'
gh api "repos/$REPO/environments/npm-publish/deployment-branch-policies" \
--jq '.branch_policies[] | [.type, .name] | @tsv'
You want required_reviewers in the first line and a tag policy for your release pattern in the second.
What to do now
-
Require reviewed pull requests on the default branch. Create a branch ruleset that also blocks force pushes and deletion:
cat > branch-ruleset.json <<'EOF' { "name": "default-branch-review", "target": "branch", "enforcement": "active", "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } }, "rules": [ { "type": "pull_request", "parameters": { "required_approving_review_count": 1, "dismiss_stale_reviews_on_push": true, "require_code_owner_review": true, "require_last_push_approval": true, "required_review_thread_resolution": false } }, { "type": "non_fast_forward" }, { "type": "deletion" } ] } EOF gh api -X POST "repos/$REPO/rulesets" --input branch-ruleset.jsonrequire_last_push_approvalmeans whoever pushed last can't be the one who approves. -
Make release owners review workflow changes. Add a
CODEOWNERSfile in.github/with a line such as/.github/ @your-org/release-managers. GitHub's docs recommend owning theCODEOWNERSfile itself the same way. -
Restrict who can create, move or delete release tags. Only bypass actors can create, update or delete matching refs:
TEAM_ID=$(gh api orgs/your-org/teams/release-managers --jq .id) cat > tag-ruleset.json <<EOF { "name": "release-tags", "target": "tag", "enforcement": "active", "bypass_actors": [ { "actor_id": $TEAM_ID, "actor_type": "Team", "bypass_mode": "always" } ], "conditions": { "ref_name": { "include": ["refs/tags/v*"], "exclude": [] } }, "rules": [ { "type": "creation" }, { "type": "update", "parameters": { "update_allows_fetch_and_merge": false } }, { "type": "deletion" } ] } EOF gh api -X POST "repos/$REPO/rulesets" --input tag-ruleset.jsonOn GitHub Enterprise plans, post the same rules to
POST /orgs/ORG/rulesetsand add arepository_namecondition, for example including~ALL. -
Gate the publish job behind an environment that only release tags can use.
gh api -X PUT "repos/$REPO/environments/npm-publish" \ -F 'reviewers[][type]=Team' -F "reviewers[][id]=$TEAM_ID" \ -F prevent_self_review=true \ -F 'deployment_branch_policy[protected_branches]=false' \ -F 'deployment_branch_policy[custom_branch_policies]=true' gh api -X POST "repos/$REPO/environments/npm-publish/deployment-branch-policies" \ -f name='v*' -f type=tagAlso turn off administrator bypass in the environment settings. GitHub allows it by default.
-
Point the workflow at that environment.
on: push: tags: - 'v*' permissions: contents: read jobs: publish: runs-on: ubuntu-latest environment: npm-publish permissions: contents: read id-token: write steps: - uses: actions/checkout@FULL_COMMIT_SHA # pin, see your inventory - uses: actions/setup-node@FULL_COMMIT_SHA with: node-version: '24' registry-url: 'https://registry.npmjs.org' - run: npm ci - run: npm publish -
Tie npm trusted publishing to the environment. On npmjs.com, under the package's Settings, Trusted publishing, fill in the organization, repository, workflow filename and the optional Environment name, here
npm-publish. npm's troubleshooting notes say all fields "are case-sensitive and must be exact." Ticknpm publishunder Allowed actions if your job runs it. Then set Publishing access to "Require two-factor authentication and disallow tokens", which npm says only affects traditional tokens. -
Consider immutable releases, which lock a published release's tag and assets.
Why it matters
Provenance answers where a package was built. It can't answer who decided to ship it. tensorlake's attestation was valid because the attacker used the real repository and workflow. What was missing was a human between push and publish.
With these settings, a stolen push token hits three walls. It can't land code on the default branch without an approving review from someone else. It can't create a v* tag unless its owner is on the release team. And a workflow edited on a side branch, as in the SubQuery case, can't enter the npm-publish environment, because only v* tags may deploy there and a reviewer still has to approve.
Try it yourself
Run step 1 on the repository behind your most downloaded package. Then trace the shortest path from a leaked token's git push to a new version on npm.
Click through it at your own pace, or open it full screen.
Quick check
Why didn't npm provenance stop tensorlake 0.5.144?
The attacker pushed to the project's own main branch, so the legitimate release workflow built and attested the malicious code. Provenance recorded where it was built, which was accurate.
What does require_last_push_approval add to a pull request rule?
The most recent reviewable push must be approved by someone other than the person who pushed it, so an attacker can't approve their own late change.
How does the environment stop a workflow edited on a feature branch?
Its deployment policy only allows refs matching the v* tag pattern, so a job running from any other branch can't enter the environment, and it no longer matches a trusted publisher configured with that environment name.
Sources
- StepSecurity: Tensorlake npm package compromised, a worm with a hostage token
- StepSecurity: SubQuery ecosystem compromised
- npm Docs: Trusted publishing for npm packages
- GitHub Docs: REST API endpoints for rules
- GitHub Docs: REST API endpoints for organization rules
- GitHub Docs: Available rules for rulesets
- GitHub Docs: About rulesets
- GitHub Docs: Deployments and environments reference
- GitHub Docs: REST API endpoints for deployment environments
- GitHub Docs: REST API endpoints for deployment branch policies
- GitHub Docs: About code owners
- GitHub Docs: Immutable releases
- GitHub Docs: Workflow syntax for GitHub Actions
- GitHub CLI manual: gh ruleset list