npm Trusted Publishingが2025年7月31日に一般公開されました。 これにより、OpenID Connect (OIDC)を使ってnpmトークンなしでCI/CDからnpmパッケージを公開できるようになりました。

この記事では、npm Trusted Publishingの仕組みや設定方法、実際のリリースフローについて紹介します。

npm Trusted Publishingとは

npm Trusted Publishingは、npmレジストリとCI/CD環境(GitHub ActionsやGitLab CI/CD)の間でOIDCベースの信頼関係を確立する仕組みです。これにより、npmトークンを使わずにパッケージを公開できます。

これまでのCIからnpmパッケージを公開する際には、長期間有効なnpmトークンをCI/CDの環境変数(Secrets)に保存する必要がありました。しかし、このアプローチにはいくつかのセキュリティリスクがあります。

  • npmトークンがCIのログや設定ファイルに誤って露出する可能性
  • トークンが侵害された場合、無効化するまで悪用される可能性
  • トークンの手動ローテーションが必要
  • 必要以上に広い権限を持つことが多い
    • granular access tokens で scope を制限できるが、monorepo では設定/維持が難しく org 単位になりがち

npm Trusted Publishingは、これらの問題を解決します。 短時間だけ有効で、かつ特定ワークフローに限定された署名付きトークンを使うため、流出しても再利用されにくい構造になっています。

対応CI/CD環境

現在、npm Trusted Publishingは次のCI/CD環境をサポートしています。

  • GitHub Actions(GitHub-hosted runners)
  • GitLab CI/CD(GitLab.com shared runners)

設定方法

npm Trusted Publishingの設定は大きく2つのステップで構成されます。

1. npmjs.comでTrusted Publisherを設定

まず、npmjs.comのパッケージ設定でTrusted Publisherを設定します。

  1. npmjs.comのパッケージ設定ページを開く
  2. “Trusted Publisher”セクションを見つける
  3. “Select your publisher”でCI/CDプロバイダーを選択

GitHub Actionsの場合は次のような設定をします。

  • Organization or user (必須): GitHubのユーザー名または組織名
  • Repository (必須): リポジトリ名
  • Workflow filename (必須): ワークフローのファイル名(例: publish.yml
    • ファイル名のみを入力(フルパスではない)
    • .ymlまたは.yaml拡張子を含める必要がある
    • ワークフローファイルは.github/workflows/内に存在する必要がある
  • Environment name (オプション): GitHub environmentsを使用する場合

2. CI/CDワークフローの設定

GitHub Actionsの設定例

ワークフローにOIDC権限 id-token: write を追加します。例えば、次のようなワークフローを設定すると、タグをプッシュしたときにnpmパッケージを公開できます。

name: Publish Package
on:
  push:
    tags:
      - 'v*'

permissions:
  id-token: write  # OIDCに必要
  contents: read

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          registry-url: 'https://registry.npmjs.org'
      
      # npm 11.5.1以降が必要
      - name: Update npm
        run: npm install -g npm@latest
        
      - run: npm ci
      - run: npm run build --if-present
      - run: npm test
      - run: npm publish

重要なのはid-token: write権限とnpm 11.5.1以降を使用することです。これにより、GitHub Actionsがnpmに対して一時的な署名付きトークンを発行できるようになります。

:memo: pnpmのpnpm publishコマンドはnpm publishに処理を移譲しているため、pnpmを使う場合もnpm 11.5.1以降が必要です。

実際のリリースフロー

自分が実際に運用している単独パッケージとmonorepoパッケージでのリリースフローを紹介します。

単独パッケージのパターン

新規パッケージのOIDCの準備

新規パッケージでは、OIDCはパッケージが一度公開されていないと設定できません。 そこで、この問題を解決するためにsetup-npm-trusted-publishというツールを作成しました。

READMEのみを含む初期パッケージを一度公開し、OIDC設定をするための下準備を自動化するツールです。 次のコマンドでパッケージを公開できるので、作成したパッケージにTrusted Publisherを手動で行います。

npx setup-npm-trusted-publish <パッケージ名>

実際のパッケージ例。

初めての公開後に、実際にCIからのPublishができるようになります。

既存パッケージのOIDCの準備

すでに一度でも公開済みであれば、そのパッケージのnpmjs.com設定画面から直接Trusted Publisherを追加できます。初期公開用の空パッケージを別途用意する手順は不要です。

CIからのPublishの実装

実運用では次の2つのWorkflowに分けています。

  1. create-release-pr.yml: リリースPRを作るだけ(publishしない)
  2. release.yml: リリースPRがマージされたら publish して GitHub Release を作る。npm 側の Trusted Publisher にはこの release.yml だけを登録する。

「PRでリリースノートを確認/編集 → マージで公開」というシンプルな二段構成です。

npmjs.comのTrusted Publisher設定では release.yml のみを指定します。create-release-pr.yml は公開権限を持たないリリースPRを作るだけのワークフローです。

そのため、user/example-pkgリポジトリで @user/example-pkgのTrusted Publisher設定は次のようになります。

  • 設定場所: https://www.npmjs.com/package/@user/example-pkg/settings/access
  • Organization or userには user を設定
  • Repositoryには example-pkg を設定
  • Workflow filenameには release.yml を設定
  • Environment name (オプション): なし

1Passwordを使った設定の効率化

Trusted Publisherの設定はnpmjs.comでの手動の作業が必要です。 手動設定でのミスを防ぐため、1Passwordのautofill機能を活用しています。

  1. 1Passwordで先にTrusted Publisherの設定項目を作成(リポジトリの情報を元にopコマンドで生成しておく)
  2. npmの設定画面で1Passwordのautofillを使用して自動入力

Trusted Publisherの設定は項目が多く間違いやすく、設定ミスを減らすことが目的です。 手入力をなくすことで、設定ミスを減らせます。

1. create-release-pr.yml の流れ

create-release-pr.ymlは手動でリリースPRを作成するワークフローです。 実行時に、アップデートするバージョンの種類(patch / minor / major)を選択します。

次のようなステップで動作します。

  1. パッケージマネージャ検出(pnpm-lock.yaml などの存在確認で npm / pnpm / yarn を選択)
  2. 依存関係インストールなしでバージョンだけ上げる(npm version 1.2.3 --no-git-tag-version 相当)
  3. 直前のリリースタグを取得
    • ある: releases/generate-notes API で差分ベースのリリースノートを生成
    • ない: 初回はテンプレ最小文言
  4. release/vX.Y.Z ブランチを作成し package.json (+ lockfile) をコミット
  5. DraftなPRを作成(ラベル: Type: Release
  6. PR本文に自動生成ノートを詰め、編集可能な状態で人間レビュー

create-release-pr.ymlは次のようなことを考慮しています。

  • GITHUB_TOKEN 権限でPRを作るため、リポジトリ設定で「Actions can create and approve PRs」をONにしておく
  • バージョン更新時に install しないので高速・副作用少なめ
  • GitHub Release Notes API で自動的に前回のリリースからの差分を取得

create-release-pr.yml は次のような内容です。

name: Create Release PR

on:
  workflow_dispatch:
    inputs:
      version:
        description: 'Version type'
        required: true
        type: choice
        options:
          - patch
          - minor
          - major

jobs:
  create-release-pr:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
    steps:
      - name: Checkout
        uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
        with:
          persist-credentials: false
      
      - name: Configure Git
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
      
      
      - name: Setup Node.js
        uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
        with:
          node-version: 'lts/*'
      
      # No need to install dependencies - npm version works without them
      - name: Version bump
        id: version
        run: |
          npm version "$VERSION_TYPE" --no-git-tag-version
          VERSION=$(jq -r '.version' package.json)
          echo "version=$VERSION" >> $GITHUB_OUTPUT
        env:
          VERSION_TYPE: $
      
      - name: Get release notes
        id: release-notes
        run: |
          # Get the default branch
          DEFAULT_BRANCH=$(gh api "repos/$GITHUB_REPOSITORY" --jq '.default_branch')
          
          # Get the latest release tag using GitHub API
          # Use the exit code to determine if a release exists
          if LAST_TAG=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq '.tag_name' 2>/dev/null); then
            echo "Previous release found: $LAST_TAG"
          else
            LAST_TAG=""
            echo "No previous releases found - this will be the first release"
          fi
          
          # Generate release notes - only include previous_tag_name if we have a valid previous tag
          echo "Generating release notes for tag: v$VERSION"
          if [ -n "$LAST_TAG" ]; then
            echo "Using previous tag: $LAST_TAG"
            RELEASE_NOTES=$(gh api \
              --method POST \
              -H "Accept: application/vnd.github+json" \
              "/repos/$GITHUB_REPOSITORY/releases/generate-notes" \
              -f "tag_name=v$VERSION" \
              -f "target_commitish=$DEFAULT_BRANCH" \
              -f "previous_tag_name=$LAST_TAG" \
              --jq '.body')
          else
            echo "Generating notes from all commits"
            RELEASE_NOTES=$(gh api \
              --method POST \
              -H "Accept: application/vnd.github+json" \
              "/repos/$GITHUB_REPOSITORY/releases/generate-notes" \
              -f "tag_name=v$VERSION" \
              -f "target_commitish=$DEFAULT_BRANCH" \
              --jq '.body')
          fi
          
          # Set release notes as environment variable
          echo "RELEASE_NOTES<<EOF" >> $GITHUB_ENV
          echo "$RELEASE_NOTES" >> $GITHUB_ENV
          echo "EOF" >> $GITHUB_ENV
        env:
          GH_TOKEN: $
          VERSION: $
          GITHUB_REPOSITORY: $

      - name: Create Pull Request
        uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
        with:
          branch: release/v$
          delete-branch: true
          title: "Release v$"
          body: |
            $
          commit-message: "chore: release v$"
          labels: |
            Type: Release
          assignees: $
          draft: true
2. release.yml の流れ

release.ymlはリリースPRがマージされたら自動でnpm publishを実行するワークフローです。

release.ymlは次のようなステップで動作します。

  1. PRがマージされたか & Type: Releaseラベルがあるかを確認
  2. package.json の version からタグ名 vX.Y.Z を決定
  3. そのタグが既に存在したら(再実行 / リトライ)何もしないで終了 → 冪等性確保
  4. Nodeセットアップ + (必要なら)依存 install / build / test
  5. npm publish --provenance (npm 11.5.1+ が必要)
  6. GitHub Release 作成
  7. 成功/失敗を元の PR にコメント

release.yml は次のような内容です。

name: Release

on:
  pull_request:
    branches:
      - master
      - main
    types:
      - closed

jobs:
  release:
    if: |
      github.event.pull_request.merged == true &&
      contains(github.event.pull_request.labels.*.name, 'Type: Release')
    runs-on: ubuntu-latest
    permissions:
      contents: write
      id-token: write  # OIDC
      pull-requests: write  # PR comment
    steps:
      - name: Checkout
        uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
        with:
          persist-credentials: false
      
      - name: Get package info
        id: package
        run: |
          VERSION=$(jq -r '.version' package.json)
          PACKAGE_NAME=$(jq -r '.name' package.json)
          echo "version=$VERSION" >> $GITHUB_OUTPUT
          echo "name=$PACKAGE_NAME" >> $GITHUB_OUTPUT
      
      - name: Check if tag exists
        id: tag-check
        run: |
          if git rev-parse "v$VERSION" >/dev/null 2>&1; then
            echo "exists=true" >> $GITHUB_OUTPUT
          else
            echo "exists=false" >> $GITHUB_OUTPUT
          fi
        env:
          VERSION: $
      
      
      - name: Setup Node.js
        if: steps.tag-check.outputs.exists == 'false'
        uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
        with:
          node-version: 'lts/*'
          registry-url: 'https://registry.npmjs.org'
      
      - name: Install latest npm
        if: steps.tag-check.outputs.exists == 'false'
        run: |
          echo "Current npm version: $(npm -v)"
          npm install -g npm@latest
          echo "Updated npm version: $(npm -v)"
      
      - name: Install dependencies
        if: steps.tag-check.outputs.exists == 'false'
        run: npm ci
      
      - name: Publish to npm with provenance
        if: steps.tag-check.outputs.exists == 'false'
        run: npm publish --provenance --access public
      
      - name: Create GitHub Release with tag
        id: create-release
        if: steps.tag-check.outputs.exists == 'false'
        run: |
          RELEASE_URL=$(gh release create "v$VERSION" \
            --title "v$VERSION" \
            --target "$SHA" \
            --notes "$PR_BODY")
          echo "url=$RELEASE_URL" >> $GITHUB_OUTPUT
        env:
          GH_TOKEN: $
          VERSION: $
          SHA: $
          PR_BODY: $
      
      - name: Comment on PR - Success
        if: |
          always() && 
          github.event_name == 'pull_request' && 
          steps.tag-check.outputs.exists == 'false' &&
          success()
        run: |
          gh pr comment "$PR_NUMBER" \
            --body "✅ **Release v$VERSION completed successfully!**
          
          - 📦 npm package: https://www.npmjs.com/package/$PACKAGE_NAME/v/$VERSION
          - 🏷️ GitHub Release: $RELEASE_URL
          - 🔗 Workflow run: $SERVER_URL/$REPOSITORY/actions/runs/$RUN_ID"
        env:
          GH_TOKEN: $
          PR_NUMBER: $
          VERSION: $
          PACKAGE_NAME: $
          RELEASE_URL: $
          SERVER_URL: $
          REPOSITORY: $
          RUN_ID: $
      
      - name: Comment on PR - Failure
        if: |
          always() && 
          github.event_name == 'pull_request' && 
          steps.tag-check.outputs.exists == 'false' &&
          failure()
        run: |
          gh pr comment "$PR_NUMBER" \
            --body "❌ **Release v$VERSION failed**
          
          Please check the workflow logs for details.
          🔗 Workflow run: $SERVER_URL/$REPOSITORY/actions/runs/$RUN_ID"
        env:
          GH_TOKEN: $
          PR_NUMBER: $
          VERSION: $
          SERVER_URL: $
          REPOSITORY: $
          RUN_ID: $
リリースを2分割にしている理由

create-release-pr.ymlrelease.ymlを分けている理由は次のとおりです。

  • create-release-pr.ymlでバージョンを間違ってもやり直しやすい(リリースPRを閉じて再度作成できる)
  • リリースPRで、リリースノートを確認/編集できる
  • リリースPRで、Contributorにmentionが飛ぶのでどのリリースで変更が入るかがわかりやすい
  • create-release-prとreleaseで分けることで、release.ymlをリトライできる

基本的にはどちらもブラウザ上で完結するので、ブラウザだけでリリースができるようになっています。

リリース手順

実際のリリース手順は次のとおりです。

  1. GitHub ActionsでPRを作成できるようにする
  • リポジトリ設定で”Allow GitHub Actions to create and approve pull requests”を有効化する
  1. リポジトリのラベルを設定
  1. Create Release PRでリリースPRを作成
  • ワークフローが自動でリリースPRを作成する
  • create release pr
  1. リリースPRをレビューしてリリースノートを書く
  • PR Bodyがそのままリリースノートになる
  • デフォルトで前回PRからの差分CHANGELOGが含まれる
  • release pr
  1. リリースPRをマージ
  • マージ後にCIが自動でnpm publishを実行する
  1. 自動リリース完了
  • npmパッケージを公開する
  • PRにリリース結果をコメントする
  • release complete

実際のサンプルは次のURLです。

単体パッケージのパターンまとめ

OIDC連携の設定は結構面倒ですが、ここまでの設定は自動化したスクリプトを書いています。

setup-release-pr-with-oidc.tsでは次のようなことをやっています。

  1. パッケージマネージャ検出: package.jsonpackageManager フィールド、なければ lockfile を見て npm / pnpm / yarn を判定する
  2. ビルド有無の確認: package.jsonscripts.build の存在を確認する
  3. ワークフローディレクトリ生成: .github/workflows/ ディレクトリを作成する
  4. Release PR 作成用Workflow生成: create-release-pr.yml をテンプレートから生成する
  5. Publish 用Workflow生成: release.yml をテンプレートから生成し OIDC (id-token: write) と provenance 付き publish を含める
  6. GitHub Actionsバージョン更新: pinact run --update で pinned された action の最新版取得を試行する(失敗時は警告表示)
  7. 変更のコミット: git status で差分を確認し workflow ファイルをコミットする(既存ならスキップ)
  8. リポジトリ情報取得: git remote get-url origin から owner/name を抽出する
  9. 手動必要設定の案内: GitHub Actions の PR 作成権限設定を有効化するよう警告を表示する
  10. npm Trusted Publisher 設定ガイド表示: npm アクセス設定ページURLと入力すべき項目(owner / repo / workflow名)を整形して出力する
  11. 1Password 連携: 1Password CLI が利用可能なら Trusted Publisher 用の項目を検索し、なければフィールド付きで作成する
  12. 対話メニュー起動: Enter で npm 設定ページをブラウザで開く / S でサマリ表示 / C で初回リリースPR作成手順案内 / Q で終了する
  13. 初回リリース手順リマインド: NPM 設定完了後に gh workflow run create-release-pr を案内する

一部の設定(npm 側の Trusted Publisher 設定、GitHub Actions の権限変更、初回ワークフロー実行など)は手動作業です。しかし、基本的にそのままの流れ作業で、手入力はなしでできるようにしています。

monorepoのパターン

monorepoの場合はsecretlintで実装している方法を使用しています。

基本的な流れは単独パッケージと同じです。

monorepoでも1つのバージョンを使うFixed modeを使ってるので、コマンドがちょっと違うだけで流れは全く同じです。

  • npm version {patch|minor|major}は、monorepoだとpnpm version {patch|minor|major} --no-git-tag-version && pnpm --recursive exec pnpm pkg set version=\"$(node -p \"JSON.parse(fs.readFileSync('package.json', 'utf8')).version\")\" で代用
  • npm publish --provenanceは、monorepoだとpnpm -r publish --no-git-checks --access public --provenanceで代用

📝 lernaはOIDCをサポートしていないため、pnpm -r publish --no-git-checks で各パッケージを npm publish しています(pnpm publishnpmコマンドを内部的に使うので、npm 11.5.1以上が必要です)。

これに追加でmonorepo用のチェックjobを実行しています。

  • provenanceを確認するワークフロー: 新規/既存パッケージでprovenance未設定なら通知する
  • pnpmによるpublish: lernaはOIDC未対応なため、内部でnpm publishを呼ぶpnpmを使用する

OIDC未連携のパッケージが残っている場合はコメントで通知します。

monorepoだと、後からパッケージが増えることがあるので、その度にOIDC設定をする必要があります。 そのため、未設定のパッケージを検出して通知するワークフローを追加しています。

それ以外は、単独のパッケージとmonorepoでは大きな違いはありません。

npm Trusted Publishing + OIDCの効果

npmトークンをSecretsに保存しなくてよくなる

Trusted Publisherを設定してOIDC連携をすると、トークンレスでnpmパッケージをCIから公開できます。 これによって、GitHub ActionsのSecretsにnpmトークンを保存する必要がなくなります。

npmトークンの漏洩リスクがなくなり、手動でやるトークンのローテーションも不要になります。

npmトークンによる公開を禁止できる

公開後はパッケージ設定で”Require two-factor authentication and disallow tokens”を有効化しています。 この設定を有効にすると公開経路を対話操作に限定できます。

これによりパケージの公開に関して次のような制約が課されます。

  • メンテナーは2要素認証が必要
  • 自動化トークンや汎用アクセストークンでの公開が禁止される

そのため、汎用のnpmトークンが漏洩してもパッケージレベルでの乗っ取りを防げるようになります。

Require two-factor authentication and disallow tokens With this option, a maintainer must have two-factor authentication enabled for their account, and they must publish interactively. Maintainers will be required to enter 2FA credentials when they perform the publish. Automation tokens and granular access tokens cannot be used to publish packages.

Requiring 2FA for package publishing and settings modification | npm Docs

Provenance生成できる

Trusted Publishingを使用すると、npmは自動でprovenance attestationsを生成・公開します。 これにより、パッケージがどこでどのようにビルドされたかを示せます。 利用者は生成元とビルド経路を機械的に検証しやすくなります。

Provenanceは、次のサイトで確認できます。

運用面の違い

  • ブラウザだけでnpmパッケージの公開とリリースノート作成が可能
  • リリースフローの標準化でメンテナーのバスファクターを軽減
  • 手動でのnpmトークン管理が不要になる
  • Protection RulesetEnvironmentのProtection Rulesなどを使わないと、GitHubの権限を渡すことがnpmの権限を渡すことと同義になる

現在の制約事項

現時点での主な制約は次のとおりです。

  • セルフホストランナーは未対応(現状はクラウドホスト型のみ)
  • 1つのパッケージに設定できるTrusted Publisherは1つだけ
  • プライベートリポジトリからはprovenanceを生成できない
  • npm CLIはv11.5.1以上が必要

不満点

実際に運用してみて感じる不満点もあります。

  • npmの最新版がNode.jsにビルトインされていないため、npm i -g npmが必要
  • GITHUB_TOKENで作成したPRではCIが即時には走らない。そのため、merge-gatekeeper等の必須チェックがあるとリリースPRがProtect Rulesにかかることがある
    • GitHub App Tokenを使うと解決するけどそこまでやるのは面倒

これが難しくて、ベストな方法がよくわかっていません。

改善ポイント

この変更によって、GitHub Actionsからnpmへのパッケージ公開においては、長期間有効なnpmトークンを使うよりはOIDCを使用することで確実によくなっています。 GitHub Actionsのsecretsにnpm tokenがなくなります。そのため、OIDCのtokenが漏れても短時間で無効になるため、攻撃難易度はあがります。

一方で、ローカルからnpmへpublishするのに比べて、GitHub Actionsからnpmにpublishすると若干リスクは変わる部分がある。 具体的には、従来はGitHubアカウントの乗っ取りがあっても、npmにpublishされることはありませんでした。 これは、GitHubとnpmが別々のアカウントであり、npmトークンが漏れない限りはnpmにpublishされることはなかったためです。

OIDC化によって、GitHubアカウントの乗っ取りにより、GitHub Actionsのワークフローを改変されてnpmにpublishされるリスクがあります。

具体的には次の2つのリスクが残っています。

  1. ワークフローファイルの改変リスク: release.yamlを直接変更されると、改変後のフローで不正なパッケージを公開される可能性がある
    • Permission的には contents: writeworkflow: writeがあれば可能
  2. 正規フローを装った攻撃: 悪意あるコンテンツを含む変更をPull Requestで通すことで、npmにマルウェアを公開することがまだ可能である
    • Permission的には contents: writeとPRにラベルを付与できる権限と、PRをマージできる権限があれば可能

npmのTrusted PublisherのドキュメントにあるようにAdditional security measuresでこれらのリスクはもう少し軽減できる。

自分の場合は、次の2つの対策を入れているけど、もっと楽でもっと良い方法があれば教えて欲しいです。

Environment Protection Ruleの設定

GitHub ActionsのEnvironmentのProtection Rulesを設定することで、release.ymlが動くときにもう一段階の認証を挟んでいます。

具体的には次のような設定をしています。

  • npmというEnvironmentを作成
  • そのEnvironmentにRequired reviewers(オーナー)を設定
  • 必ず手動レビューが通らないと実際にはリリースできないようにチェックを設定
  • 注意: npm 側の OIDC 設定で Environment を npm に設定する必要がある
# .github/workflows/release.yml
jobs:
  publish:
    runs-on: ubuntu-latest
    environment: npm  # 作成したEnvironment名 = npmのODC設定にもEnvironment名を設定する必要がある
    steps:
      # ...

この設定をすると、Actionが走る時に手動でレビューを通るような形にできます。(Required reviewersを入れなければ自動で通る)

environment protection

ConfirmするとActionが実行される。

手動でOK

チーム内でデプロイは誰かにチェック必須にしたい場合などに、この機能が利用できます。

CODEOWNERSによるワークフロー保護

release.yml自体が変更されることを防ぐため、次の対策を実装している。

  1. CODEOWNERSファイルの設定: ワークフローファイルの変更にはオーナーのレビューが必須
# CODEOWNERS
.github/CODEOWNERS @your-username
.github/workflows/ @your-username
  1. Branch Protection Rules: メインブランチへの直接プッシュを禁止し、必ず Pull Request を経由するルールを設定する。併せて “Require review from Code Owners” を有効化する。

これらの対策により、攻撃者がプロテクションルールを回避するにはPull Requestを通して変更する必要があります。 また、Code Ownersのレビューが必須になるため、攻撃者はオーナーのアカウントを乗っ取るか、Code Ownersに入っている人を乗っ取る必要があります。

Pull Request は公開で行われるため攻撃は可視化され、攻撃者にとって実行しづらくなります。

将来的な改善点

Protection RulesetのRestrict file paths(Enterprise)を使用することで、ファイルパスに対するより厳格なプロテクションルールを設定できるようになります。 これを活用すると、release.ymlに対する変更ルールをさらに厳しく設定できます。ただし、パブリックリポジトリではまだこの機能が利用できない状況です。

また、Protection RulesetやEnvironment Protection RuleがAPIで削除できるようになっています。そのため、アカウントが持っているGitHub Tokenが漏洩すると、これらのルールを削除して攻撃することも可能になってしまう。GitHub Actionsの secrets.GITHUB_TOKEN の漏洩への対策にはなっているが、アカウントのGitHub Tokenの漏洩への対策にはなっていないという問題があります。

release.ymlに関する処理を改ざんするには、必ずMFAを使わないといけないみたいな設定方法があればいいのですが、その方法が現状だとわかっていないです。

対策まとめ

現在のOIDC化とEnvironment + Protection Ruleset(CODEOWNERS)の組み合わせにより、次の効果が得られています。

  • 攻撃者は攻撃に公開Pull Requestを経由する必要がある
  • 水面下でのワークフロー改変やGitHub Actionsのトークン(secrets.GITHUB_TOKEN)の悪用が難しくなった
  • ローカルのnpm tokenが漏洩しても、既存のnpmパッケージへのマルウェア版の上書きを防げるようになった
    • require two-factor authentication and disallow tokensでMFAが必須になるため
  • リリースプロセスの透明性が向上した

完全な防御は不可能ですが、攻撃コストは結構上がる状態が作られている形です。

Require two-factor authentication and disallow tokensにより、ローカルのnpmトークンが漏洩した場合の対策が可能になっています。パッケージレベルでnpmトークンのみでのpublishができなくなるためです。 そのため、影響が大きいパッケージは”Require two-factor authentication and disallow tokens”が有効になっている状態が良いです。

“Require two-factor authentication and disallow tokens” は OIDC 化とは別に設定できます。しかし monorepo では手動認証を都度挟むのは数と時間の都合で難しく、結果として未設定パッケージが多い状態でした。

まとめ

Trusted Publishingを導入することで公開フローの属人性を下げつつ、長期間有効なnpm tokenなしにnpmパッケージを安全に公開できるようになります。また、Provenanceも自動生成されるため、サプライチェーンセキュリティに対する信頼性も向上します。

また、リリースフローをCI/CDにまとめることで、メンテナンスの負担軽減とセキュリティ向上を両立できます。新規パッケージや既存パッケージ、monorepoにも対応可能なため、ぜひ導入を検討してみてください。

実はこのリリースフローは3世代目です。 今まではmonorepoでしかやってなかった(npm token管理が大変なのでやれなかった)のですが、ブラウザだけで、リリースノート書けるし、Contirbutorにどのリリースで入るかが通知できて、パッケージ公開できる仕組みになっています。

参考資料