はじめに
業務でGitLab上に複数のNode.js製アプリケーションを管理していますが、最近はAIによるコード生成や自動レビューの普及もあってか、脆弱性対応の頻度が以前より増えてきました。 1つのアプリだけであれば npm audit を都度走らせれば済みますが、十数個のリポジトリに同じパッケージが散らばっていると、まずどのリポジトリで何のバージョンが使われているのかを把握するだけでも手間がかかります。
そこで、GitLab REST APIを使い、自分のネームスペースやグループ配下のリポジトリを横断して package-lock.json や yarn.lock を回収し、特定のパッケージのバージョンを一覧化するスクリプトを作成しました。 本記事では uv で環境を整えながらPythonでスクリプトを実装する手順を紹介します。
方針
ざっくり以下の流れで処理します。
- パーソナルアクセストークンを使って、自分のネームスペースまたは指定したグループ配下のプロジェクト一覧を取得する
- 各プロジェクトのリポジトリツリーを再帰的に走査し、
package-lock.json/yarn.lock/pnpm-lock.yamlのいずれかを探す - 見つかったロックファイルの中身を取得し、対象パッケージのバージョンを抽出する
- リポジトリ名・パス・バージョンの一覧を出力する
図にすると以下のような流れになります。

ロックファイルを対象にしているのは、package.json だけだとレンジ指定(^1.2.3 など)になっていて実際に解決されたバージョンが分からないためです。
使用環境
| 項目 | バージョン |
|---|---|
| Python | 3.12以降 |
| uv | 0.5.x |
| python-dotenv | 1.x |
| GitLab | 17.x(self-managed / SaaSのいずれでも可) |
事前に以下を準備しておきます。
uvのインストール- GitLabのパーソナルアクセストークン(スコープ:
read_api,read_repository)
トークンはユーザー設定の Access Tokens から発行できます。 read_api と read_repository の2つだけあれば本記事の用途には十分です。
uv で新規プロジェクトを作成し、HTTPクライアントとして httpx、環境変数読み込み用として python-dotenv を追加します。
# uv のインストール
curl -LsSf https://astral.sh/uv/install.sh | sh
# プロジェクト作成と依存パッケージの追加
uv init scan-node-versions
cd scan-node-versions
uv add httpx python-dotenvパーソナルアクセストークンの注意点
トークンはGitLab APIへの認証情報であり、漏洩すると自分の権限でリポジトリを読み書きされる可能性があります。 以下の点に注意して取り扱います。
- 必要最小限のスコープ(今回は
read_apiとread_repository)のみを付与する - 有効期限を短めに設定する
- 環境変数や
.envファイルなどコミットされない場所に保存する - 不要になったら速やかに失効させる
注意:本記事のスクリプトでは .env ファイルを使用していますが、誤ってコミットしないように
.gitignoreに追加することを強く推奨します。
スクリプト本体
プロジェクト直下に scan_node_versions.py を作成し、以下のコードを記述します。
"""GitLab上のリポジトリを横断してnodeパッケージのバージョンを調べるスクリプト。"""
import argparse
import base64
import json
import os
import sys
from urllib.parse import quote
import httpx
from dotenv import load_dotenv
# 対象とするロックファイル名
LOCKFILES = ("package-lock.json", "yarn.lock", "pnpm-lock.yaml")
def gitlab_client(base_url: str, token: str) -> httpx.Client:
"""GitLab API用のhttpxクライアントを生成する。"""
return httpx.Client(
base_url=base_url.rstrip("/") + "/api/v4",
headers={"PRIVATE-TOKEN": token},
timeout=30.0,
)
def iter_paginated(client: httpx.Client, path: str, params: dict) -> list:
"""ページネーション付きエンドポイントを最後までたどる。"""
results = []
page = 1
while True:
resp = client.get(path, params={**params, "page": page, "per_page": 100})
resp.raise_for_status()
items = resp.json()
if not items:
break
results.extend(items)
# 次ページの有無はレスポンスヘッダーで判定する
next_page = resp.headers.get("x-next-page")
if not next_page:
break
page = int(next_page)
return results
def list_projects(client: httpx.Client, group: str | None) -> list[dict]:
"""ネームスペースまたはグループ配下のプロジェクト一覧を取得する。"""
if group:
# グループ配下のプロジェクトをサブグループも含めて取得する
path = f"/groups/{quote(group, safe='')}/projects"
params = {"include_subgroups": "true", "archived": "false"}
else:
# 自分が所属しているプロジェクトのみを対象にする
path = "/projects"
params = {"membership": "true", "archived": "false"}
return iter_paginated(client, path, params)
def list_lockfiles(client: httpx.Client, project_id: int, ref: str) -> list[dict]:
"""リポジトリツリーを再帰的に走査してロックファイルだけを抽出する。"""
path = f"/projects/{project_id}/repository/tree"
params = {"recursive": "true", "ref": ref}
try:
items = iter_paginated(client, path, params)
except httpx.HTTPStatusError as e:
# 空リポジトリなどでは404が返るのでスキップする
if e.response.status_code in (403, 404):
return []
raise
return [item for item in items if item["name"] in LOCKFILES]
def fetch_file(client: httpx.Client, project_id: int, file_path: str, ref: str) -> str:
"""指定ファイルの中身を文字列で取得する。"""
encoded = quote(file_path, safe="")
resp = client.get(f"/projects/{project_id}/repository/files/{encoded}",
params={"ref": ref})
resp.raise_for_status()
data = resp.json()
# contentはbase64エンコードされている
return base64.b64decode(data["content"]).decode("utf-8", errors="replace")
def extract_versions(content: str, filename: str, package: str) -> list[str]:
"""ロックファイルの中身から対象パッケージのバージョンを抜き出す。"""
versions: list[str] = []
if filename == "package-lock.json":
try:
doc = json.loads(content)
except json.JSONDecodeError:
return versions
# lockfileVersion 2/3はpackagesキー配下にフラットに格納される
for key, meta in (doc.get("packages") or {}).items():
# キーは "node_modules/<name>" または "node_modules/<scope>/<name>"
if key.endswith(f"node_modules/{package}") and "version" in meta:
versions.append(meta["version"])
# 古いlockfileVersion 1向けのフォールバック
if not versions:
deps = doc.get("dependencies") or {}
if package in deps and "version" in deps[package]:
versions.append(deps[package]["version"])
elif filename == "yarn.lock":
# yarn.lockはテキスト形式なので簡易パースで対応する
for block in content.split("\n\n"):
header = block.splitlines()[0] if block else ""
if f'"{package}@' in header or header.startswith(f"{package}@"):
for line in block.splitlines():
line = line.strip()
if line.startswith("version "):
versions.append(line.split()[1].strip('"'))
break
elif filename == "pnpm-lock.yaml":
# pnpmは "/<name>@<version>:" の形式でパッケージが現れる
prefix = f"/{package}@"
for line in content.splitlines():
line = line.strip()
if line.startswith(prefix) and line.endswith(":"):
versions.append(line[len(prefix):-1])
return sorted(set(versions))
def main() -> int:
load_dotenv()
parser = argparse.ArgumentParser(description="GitLabリポジトリを横断してnodeパッケージのバージョンを集計する")
parser.add_argument("package", help="調べたいパッケージ名(例: axios, next)")
parser.add_argument("--gitlab-url", default=os.environ.get("GITLAB_URL", "https://gitlab.com"))
parser.add_argument("--group", default=None, help="対象のグループパス(省略時は自分の所属プロジェクト)")
args = parser.parse_args()
token = os.environ.get("GITLAB_TOKEN")
if not token:
print("環境変数 GITLAB_TOKEN を設定してください。", file=sys.stderr)
return 1
with gitlab_client(args.gitlab_url, token) as client:
projects = list_projects(client, args.group)
print(f"対象プロジェクト数: {len(projects)}", file=sys.stderr)
hits: list[tuple[str, str, str, str]] = []
for project in projects:
ref = project.get("default_branch")
if not ref:
continue
lockfiles = list_lockfiles(client, project["id"], ref)
for lf in lockfiles:
content = fetch_file(client, project["id"], lf["path"], ref)
versions = extract_versions(content, lf["name"], args.package)
for v in versions:
hits.append((project["path_with_namespace"], lf["path"], lf["name"], v))
# 結果をTSVで出力する(spreadsheetに貼り付けやすいため)
print("project\tlockfile\ttype\tversion")
for project_path, file_path, filetype, version in hits:
print(f"{project_path}\t{file_path}\t{filetype}\t{version}")
return 0
if __name__ == "__main__":
sys.exit(main())ポイントをいくつか補足します。
ページネーション:GitLab APIは1リクエスト最大100件までしか返さないため、x-next-page ヘッダーを見ながら最後のページまで取得しています。
ツリーの再帰取得:/repository/tree エンドポイントに recursive=true を渡すことで、リポジトリ全体のファイル一覧を取得できます。サブディレクトリにモノレポ構成のロックファイルがあっても拾えます。
dotenvによる設定の読み込み:main() の先頭で load_dotenv() を呼ぶことで、プロジェクトルートの .env ファイルを自動的に読み込み、GITLAB_TOKEN や GITLAB_URL を環境変数として展開します。.env ファイルが存在しない場合でもエラーにはならないため、CI環境など実際の環境変数が設定されている場面でもそのまま動作します。
ロックファイルの解析:package-lock.json はJSONとしてパース可能ですが、yarn.lock と pnpm-lock.yaml はそれぞれ独自形式です。今回は対象パッケージ名の検出だけが目的のため、簡易的な行ベースのパースで済ませています。
実行方法
プロジェクトルートに .env ファイルを作成してトークンを記載します。スクリプト起動時に load_dotenv() が自動的に読み込みます。
GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx
GITLAB_URL=https://gitlab.example.com
.envファイルは.gitignoreに追加して絶対にコミットしないようにしてください。
# 自分が所属するすべてのプロジェクトを対象にする
uv run python scan_node_versions.py axios
# 特定のグループ配下のみを対象にする
uv run python scan_node_versions.py axios --group my-team/web出力例:
project lockfile type version
my-team/web/admin-ui package-lock.json package-lock.json 1.7.4
my-team/web/customer-portal package-lock.json package-lock.json 1.6.8
my-team/web/legacy-app yarn.lock yarn.lock 0.27.2
my-team/internal/dashboard pnpm-lock.yaml pnpm-lock.yaml 1.7.4
TSV形式で出力しているため、そのままスプレッドシートに貼り付けて並べ替えやフィルタができます。 脆弱性のある古いバージョンを使っているリポジトリを上から順に潰していくのに便利です。
運用上のハマりどころ
実際に十数個のリポジトリを抱えるネームスペースで運用してみたところ、いくつか想定外の挙動に遭遇しました。同じことに悩む方の参考になればと思い、気付いた点をまとめておきます。
アーカイブ済みプロジェクトと空リポジトリ
GitLabではアーカイブしたプロジェクトもAPIのデフォルトでは返却対象になります。アーカイブ済みのリポジトリはそもそもメンテナンス対象外なので、本記事のスクリプトでは archived=false をクエリパラメータに付けて除外しています。
また、新規作成直後でまだコミットが無い空リポジトリは default_branch が null になるため、そのまま /repository/tree を叩くと404が返ります。スクリプト側では default_branch の有無をチェックしてスキップする実装にしてありますが、独自に拡張する場合はこの分岐を残しておくと安全です。
モノレポでツリー再帰がタイムアウトする
pages/ 配下に数百のサブディレクトリを抱えるような大規模なモノレポでは、/repository/tree?recursive=true のレスポンスが30秒のタイムアウトに収まらないことがありました。対策としては以下のいずれかが有効です。
httpx.Clientのtimeoutを60.0程度に伸ばすrecursive=falseにしてpathパラメータでサブディレクトリを限定し、apps/packages/など命名規約の決まったディレクトリだけを順番に走査する- 事前に
default_branchのサイズ(コミット数やツリーサイズ)を見て、しきい値を超えるリポジトリだけ別の処理経路に回す
筆者の運用では、対象が10リポジトリ程度であればタイムアウトを伸ばすだけで十分間に合っています。
応用:脆弱バージョンの判定を組み込む
GitHub Advisory DatabaseやnpmのCVE情報と組み合わせれば、特定バージョンが脆弱性の影響範囲に入っているかを自動判定することもできます。 たとえば「axios@1.7.4 未満は影響あり」のような閾値判定を extract_versions の結果に対して行えば、対応必須のリポジトリだけをハイライトできます。
スクリプト側で判定まで行うとロジックが肥大化しがちなため、筆者は調査用ツールとしての役割は一覧化までに絞り、TSVで出した結果を別途LLMに食わせて優先度付けをしてもらう運用にしています。
まとめ
GitLab REST APIとパーソナルアクセストークンを組み合わせることで、複数リポジトリにまたがるNode.jsパッケージのバージョンを横断的に調査できます。 uv を使うとプロジェクト作成から依存パッケージの追加までが数コマンドで完結するため、こうした調査用の使い捨てツールも気軽に整備できるのが嬉しいところです。
実際に運用してみると、脆弱性のCVEが公表された直後に「どのリポジトリの対応が必須なのか」を5分以内に確定できるようになり、対応の心理的ハードルが大きく下がりました。これまではリポジトリを1つずつcloneして npm ls を叩いていたため、対象の特定だけで半日かかることもあったところを考えると、初動の速さは目に見えて改善できています。
今後はGitHub Advisory DatabaseのAPIと組み合わせて、CVE番号を入力するだけで影響範囲のリポジトリだけを自動でハイライトする仕組みまで踏み込んでみたいと考えています。同じく複数リポジトリの脆弱性対応に追われている方の参考になれば幸いです。


コメント