はじめに
Qiita記事を管理する、Markdownのドキュメント専用のリポジトリ(非公開)で、Qiita連携をPythonスクリプトから外部依存ゼロのGoバイナリ(QiitaClientGo)に置き換え、あわせてGitHub Actionsやシークレット管理も多層的に堅牢化しました。
この記事では、次の内容を整理します。
- 何を脅威として想定し、どう対策したか
- 元々のPython連携がどんな仕組みだったか
- QiitaClientGoが学習用の実装から正式なOSSになるまでの経緯と、OSSのリリースの仕組みで問題をどう解消したか
- 既存のPythonスクリプトを残したまま先に行った即時対策(フェーズ1)と、バイナリ移行(フェーズ2)の進め方
Qiita記事をGitHubで管理し、GitHub Actionsで自動同期している開発者の方を想定しています。
先日、Qiita投稿を「PRをマージするだけ」にする自動パイプラインの記事(参考欄に記載)も公開されていました。向いている課題が違うため、後半で比較しています。この記事は連携処理そのものをどこまで信頼できる形にするかに焦点を当てています。
1. 背景と脅威モデル
Qiita連携という小さな処理でも、依存パッケージ・CI・トークンという3つの攻撃面を持っています。
当初の連携は、Pythonスクリプト2本(記事の取得と投稿)をGitHub Actionsから呼び出す構成でした。動いてはいたものの、見直すと次のようなリスクが残っていました。
| 脅威 / リスク | 見直し前の状態 | 対策 |
|---|---|---|
| サプライチェーン攻撃 |
requests、pyyaml を pip install で導入(バージョン未固定) |
外部依存ゼロのGoバイナリへ移行 |
| Actionsの改ざん | 存在しないメジャーバージョン(@v6)を指定 |
コミットSHAでピン留め、Dependabotで追従 |
| 機密情報の漏洩 |
.gitignore が最小限で、検査の仕組みもない |
.gitignore 強化とGitleaksの導入 |
| 外部入力による不正操作 | Qiitaの記事タイトルをそのままファイル名に使用 | サニタイズとスコープ検証 |
| ハング | HTTPタイムアウト未設定 | 全通信に30秒のタイムアウト |
| CIの無限連鎖 | 書き戻しコミットが再びCIを起動しうる | コミットメッセージに [skip ci]
|
特にサプライチェーン攻撃は、自分のコードに問題がなくても依存先の更新で被害を受けうる点が厄介です。個人の記事管理リポジトリであっても、Qiita APIのトークンを扱うため、無視できないと判断しました。
2. 元々のPython連携の仕組み
元の連携は「GitHub Actions → Pythonスクリプト → Qiita API」というシンプルな構成で、Pythonの実行環境とpipパッケージをCIの実行ごとに用意することが前提でした。
2.1 構成要素
連携は、次の2つのGitHub Actionsワークフローで構成していました。
| ワークフロー | 役割 |
|---|---|
sync_to_qiita.yml |
リポジトリのMarkdownをQiitaへ同期する(投稿・更新) |
sync_from_qiita.yml |
Qiita上の記事をリポジトリのMarkdownへ同期する(取得) |
どちらも内部でPythonスクリプトを実行し、HTTP通信とfrontmatter(YAML)の解析には外部ライブラリの requests と pyyaml を使っていました。記事は、frontmatterにタイトルやタグなどを持つMarkdownファイルとして管理しています。
2.2 ワークフローの流れ
どちらのワークフローも、おおむね次の手順で動いていました。
-
actions/checkoutでリポジトリを取得する -
actions/setup-pythonでPythonを用意する -
pip installでrequestsとpyyamlを導入する - Pythonスクリプトを実行し、Qiita APIと通信する
- 変更があればコミットしてpushする
2.3 見直しで見えた弱点
この構成を見直すと、サプライチェーンの観点で次のような弱点がありました。
- 毎回のCIで
pip installを実行するため、依存パッケージ(と、その先の依存)の状態に実行結果が左右される - バージョンが固定されておらず、同じワークフローでも実行時期によって中身が変わりうる
- 取得側がタイトルをファイル名に使うため、外部入力がそのままファイルパスになる
- HTTPタイムアウトがなく、API側の遅延でジョブが止まりうる
つまり「動いているが、実行のたびに外部の状態を信頼している」構成だった、というのが見直し前の評価です。
3. フェーズ1: Pythonのまま先にできる対策をすべて入れる
バイナリ移行を待たずに、リポジトリとCIに対する対策を先に完了させました。移行には時間がかかるため、現行構成のリスクを先に下げる順序にしています。
3.1 .gitignore の強化
次のカテゴリを追加しました。
- Python生成物(
__pycache__/、*.pyc、venv/、.venv/) - クレデンシャルと秘密鍵(
.env*、*.pem、*.key、*.p12、*.pfx) - エディタ・OSの一時ファイル(
.vscode/、.idea/、*.swp、Thumbs.db)
3.2 DependabotでActionsを追従
.github/dependabot.yml で、GitHub Actionsの更新を週次で確認するようにしました。コミットSHAでピン留めすると更新が止まりやすくなるため、追従を自動化する組み合わせが前提になります。
# .github/dependabot.yml(例)
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
3.3 Gitleaksでシークレットを自動検査
.github/workflows/gitleaks.yml を追加し、PRとpushの際にコミット履歴の機密情報混入を検出します。.gitignore は「入れない」ための仕組みで、Gitleaksは「入ってしまったら気づく」ための仕組みです。役割が違うので両方入れています。
3.4 Actionsのピン留めと権限の最小化
タグは付け替えが可能なため、サードパーティActionはコミットSHAで固定しました。読みやすさのため、コメントで元のバージョンを残しています。
# 例: コミットSHAで固定し、コメントにバージョンを残す
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
あわせて、requests==2.32.3 と pyyaml==6.0.2 のバージョンを固定し、スクリプトで使っていない GITHUB_TOKEN 環境変数の露出も削除しました。
4. QiitaClientGoとは
QiitaClientGoはQiita API連携とGo言語を学ぶための実装から出発し、正式なOSSとして育てたツールです。連携処理を置き換える受け皿として、OSSのリリースの仕組みごと取り入れました。
4.1 学習用の実装から始まった
QiitaClientGoは、もともと「Qiita APIとの連携」と「Go言語」の両方を学ぶための実装でした。Goは標準ライブラリだけでHTTP通信やJSONの処理ができるため、学習用であっても外部ライブラリに頼らずに書けます。この性質が、のちに「外部依存ゼロ」という設計方針につながりました。
4.2 改良して正式なOSSとしてリリースした
学習用のコードのままでは、自分の運用に組み込むには不十分でした。そこで機能と品質を見直して改良し、正式なOSSとして公開・リリースしました。リポジトリは SRAUFactory/QiitaClientGo です。
OSSとして整える際の主な改良点は、Issue #4 で次の3つのサブコマンド構成にしたことです。
| サブコマンド | 役割 |
|---|---|
fetch |
Qiita APIから記事一覧を取得し、frontmatter付きMarkdownとして保存する。ページネーション、既存ファイルとの差分比較、パストラバーサル防止に対応 |
push |
frontmatter(title、tags、private、qiita_id)を解析し、Qiita APIへ新規投稿または更新する。新規投稿時は qiita_id を書き戻す |
diff |
変更されたMarkdownを検出し、push 対象として出力する |
Pythonで2本に分かれていた処理が、取得(fetch)、投稿(push)、差分検出(diff)という役割で整理され、1つのバイナリにまとまりました。セキュリティ面の要件も、最初から次の2点を守る前提にしています。
- 外部ライブラリゼロ(Go標準ライブラリのみ)を厳守する
- HTTPタイムアウトとパストラバーサル検証を実装する
4.3 OSSのリリースの仕組みで問題を解消した
Pythonでは「実行のたびに依存を取得する」ことが課題でしたが、OSSのリリース成果物を使う形にすることで、「検証済みの固定された実行物を使う」形に変えられました。
| 課題(Python連携) | OSSのリリースの仕組みによる解消 |
|---|---|
実行のたびに pip install で依存を取得する |
ビルド済みの単一バイナリをReleasesから取得するだけでよい |
| バージョンが固定されておらず、実行時期で中身が変わりうる | リリースタグ(v*)で利用バージョンを固定できる |
| 取得したものが正しいか確かめる手段がない |
checksums.txt(SHA256)で整合性を検証できる |
| 実行環境(Python、pip)を毎回構築する必要がある | Linux / macOS向けにビルド済みなので、環境構築が不要 |
この仕組みは、.github/workflows/release.yml で自動化しています。
-
v*タグをpushすると、LinuxとmacOS向けにクロスコンパイルする -
sha256sumでチェックサムファイル(checksums.txt)を自動生成する - バイナリとチェックサムをGitHub Releasesに公開する
手作業でビルドして配布するのではなく、タグを打つだけでリリースが整う形にしたことで、「配布の手順そのもの」も再現可能になりました。
5. フェーズ2: ドキュメント専用リポジトリ側の移行
QiitaClientGoを取り込んだことで、ワークフローから pip install が消えました。依存パッケージが存在しなければ、依存パッケージ経由の攻撃も成立しません。
sync_to_qiita.yml と sync_from_qiita.yml を、次のように変更しました。
- Pythonのセットアップと
pip installを廃止 - QiitaClientGoのReleasesからバイナリを取得
- SHA256チェックサムで整合性を検証してから実行
-
git commitとgit pushは、ワークフロー内のシェルステップで実行([skip ci]付き)
バイナリ取得と検証のイメージは次のとおりです。ファイル名やURLは説明用の仮のものです。
# 例: バイナリを取得し、チェックサム検証に通ったものだけを実行する(名称は仮)
- name: Download QiitaClientGo
run: |
VERSION="vX.Y.Z" # 利用するバージョンを固定する
BASE="https://github.com/SRAUFactory/QiitaClientGo/releases/download/${VERSION}"
curl -fsSL -O "${BASE}/qiita-client_linux_amd64"
curl -fsSL -O "${BASE}/checksums.txt"
# 対象ファイルの行だけを抜き出して検証する(不一致なら非ゼロで終了)
grep " qiita-client_linux_amd64$" checksums.txt | sha256sum -c -
chmod +x qiita-client_linux_amd64
移行が済んだ時点で、scripts/fetch_from_qiita.py と scripts/post_to_qiita.py はリポジトリから完全に削除しました。なお、ローカルで使う scripts/fetch_github_repos_languages.sh は連携処理とは別のため残しています。
5.1 チェックサム検証の限界
同じReleasesに置かれたバイナリとチェックサムを検証する方式は、ダウンロード中の破損や取得経路での差し替えには有効です。一方で、リリースそのものが改ざんされた場合は、チェックサムも一緒に差し替えられる可能性があります。この点は過信せず、利用バージョンの固定とあわせて運用する前提にしています。
6. 改善効果
| 評価項目 | 対策前 | 対策後 |
|---|---|---|
| サードパーティ依存 | Pythonの requests や pyyaml(pip未固定) |
外部依存ゼロ(Go標準ライブラリの単一バイナリ) |
| GitHub Actions | 存在しないメジャーバージョン指定(@v6) |
コミットSHAでピン留めし、Dependabotで自動追従 |
| シークレット管理 |
.gitignore が最小限で検査の仕組みもない |
包括的な .gitignore とGitleaksによる自動検査 |
| パストラバーサル | Qiitaのタイトルによるディレクトリ外への書き込みの恐れ | サニタイズとディレクトリ相対パスの検証 |
| ハング・タイムアウト | HTTPタイムアウト未設定 | 全通信に明示的なタイムアウト(30秒) |
| CI実行効率 | 書き戻しコミットでCIが連鎖する懸念 |
[skip ci] で不要なビルドを抑止 |
7. 類似の自動投稿パイプラインとの違い
目的の軸が違うため、競合するというよりは補完関係にあると考えています。
最近、「PRをマージするだけ」でQiitaへ自動投稿するパイプラインを紹介した記事が公開されました。そちらはAIエージェントが書いた記事を、人間がPRマージで承認してから公開するフローが中心で、Qiitaとの同期には公式のQiita CLIとGitHub Actionsを組み合わせる構成です(詳細は参考欄の記事をご覧ください)。
| 観点 | PRマージで公開する構成 | この記事の構成 |
|---|---|---|
| 主な関心 | 公開判断を人間に残す(承認フロー) | 連携処理自体の供給網リスクを減らす |
| Qiitaとの同期 | 公式Qiita CLI | 自前実装のGoバイナリ(QiitaClientGo) |
| 依存関係 | 公式CLIを利用 | Go標準ライブラリのみで外部依存ゼロ |
| 実行物の検証 | — | SHA256チェックサムで検証してから実行 |
| 外部入力の扱い | — | タイトル由来のパストラバーサルを防止 |
自前実装には、保守の手間が増えるという代償があります。その一方で、通信の挙動、ファイルの書き込み先、依存関係を自分で把握できる点は大きな利点でした。また、QiitaClientGoはOSSとして公開しているため、同じ課題を持つ方が利用・改善に参加することもできます。たとえばタイムアウトやパス検証は、仕様として最初から入れられます。両者は組み合わせることもでき、承認フローを重視する場合は、同期部分だけをQiitaClientGoに置き換える構成も考えられます。
まとめ
結論として、Qiita連携を依存のない検証済みバイナリに置き換えることで、供給網のリスクを大きく減らせました。要点は次のとおりです。
- Qiita連携という小さな処理でも、依存パッケージ、CI、トークンに攻撃面があり、多層で対策した
- 元のPython連携は、実行のたびに
pip installで依存を取得する構成で、バージョンの固定も検証手段もなかった - QiitaClientGoは、Qiita API連携とGoの学習用実装から、正式なOSSへ改良した。
fetch、push、diffを持つ外部依存ゼロのバイナリである - タグpushによるクロスコンパイルとチェックサム公開というOSSのリリースの仕組みで、「検証済みの固定された実行物を使う」形に変えられた
- 承認フローを重視する構成とは目的が異なり、同期処理の信頼性を高める方向で差別化できる
Qiita連携を自前で実装することは、手間と引き換えに挙動と依存関係を自分で把握できるという選択でもあります。同じ悩みを持つ方の参考になれば幸いです。