diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000000..bc7008d00d
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,165 @@
+## 概要 (Summary)
+
+*
+
+## 関連Issue / チケット (Related Issues)
+* close #
+
+## 変更タイプ (Type of Change)
+- [ ] 🚀 新機能追加 (Feature)
+- [ ] 🐛 バグ修正 (Bug Fix)
+- [ ] 🔒 セキュリティ修正 (Security Fix)
+- [ ] 🚫 機能のクローズ・非公開化・削除 (Feature Deprecation/Disable)
+- [ ] ⚠️ 破壊的変更・データ移行を伴う修正 (Breaking Change / Migration)
+- [ ] 📚 仕様書・マニュアル・APIリストの更新 (Documentation)
+
+---
+
+## 🤖 0. CI 自動チェック (API Inventory Drift)
+
+
+PR ごとに実機を起動し、url_map のダンプ・台帳との突き合わせ・変更行の到達可否測定を自動実行する。
+結果は PR コメントと Actions の artifact (`api-inventory-drift`) に出る。
+
+- [ ] **CI が PASS している**、または FAIL の各項目に対処済み
+
+### API を追加・変更した場合(必須)
+
+- [ ] **秘密側**の `api_snapshot.json` を更新し、対応する PR を出した
+ ```bash
+ export WEKO_API_INVENTORY_DIR=/path/to/weko-secret
+ ./install.sh
+ python3 tools/api-inventory/scripts/snapshot.py --out "$WEKO_API_INVENTORY_DIR/api_snapshot.json"
+ ```
+ 更新しないと CI が落ちる。**このリポジトリは public のため台帳もベースラインも
+ 同梱していない。** 更新は秘密リポジトリ側の PR になる。
+- [ ] **秘密側**の `weko3_api_list_full.tsv` に行を追加・更新し、
+ `build_checklist.py` で 24 列版を再生成した(未収載だと reconcile が FAIL する)
+- [ ] 台帳・スナップショット・実測結果を**この公開リポジトリにコミットしていない**
+ (`git status` に `*.tsv` / `api_snapshot.json` が出ていないこと)
+
+### FAIL したときの対処(要約)
+
+| 検出 | 意味 | 対処 |
+|---|---|---|
+| G1 / G2 | 新規に認証デコレータが無い / 認証デコレータが削除された | 実装を直す。意図的な公開なら台帳に根拠を書いてベースライン更新 |
+| G3 | 認証・認可のコメントアウトが増えた | 原則やり直し |
+| G4 | `*_PERMISSION_FACTORY` 等が危険側に変わった | 原則やり直し |
+| G5 | ModelView の `can_delete` / `can_export` を有効化 | 意図的なら台帳の `data_op` を更新 |
+| G6 / G7 | 属性不明の経路が増えた / 依存更新で経路が増減 | 台帳に行を追加してレビューする |
+| **G8** | **未認証で到達する書き込み系** | **原則やり直し** |
+| **G9** | **台帳では遮断なのに実測で到達(認可の回帰)** | **原則やり直し** |
+| reconcile A–D | 台帳と実機の不一致(未収載・メソッド・app 列) | 台帳を実機に合わせる |
+
+実機に存在しないことが正当な行(プラグイン未登録等)は**秘密側**の `reconcile_allow.json` に
+**理由付きで**登録する。理由なしの登録は不可。
+
+> CI の出力は件数のみ。該当した経路名は Actions には出ないので、秘密側の完全版レポートで確認すること
+> (このリポジトリは public で、ログ・artifact・PR コメントは誰でも読めるため)。
+
+---
+
+## 🔒 1. セキュリティ & API アクセス制御チェック (必須)
+
+### 認証・認可 (Authentication & Authorization)
+- [ ] 新規/変更された Blueprint・View・REST リソースに適切なデコレータ / Permission を設定している
+ - 例: `@login_required`, `@pass_record`, `need(...)`, Invenio Access Action
+ - `/api/*` では `Permission.require(http_exception=403)` を使うこと。
+ `@login_required` は API アプリに `security.login` が無いため 401 ではなく **500** になる
+ - CI: G1 / G2 が自動検出(デコレータの有無・削除)
+- [ ] 状態変更・破壊的メソッド (POST / PUT / PATCH / DELETE) の権限が正しく制限されている
+ - CI: G8 が変更行を未認証で実測
+- [ ] 未ログイン(Anonymous)状態でアクセスした際、意図しないデータ取得・変更が拒絶される
+ - CI: G8 / G9 が変更行を実測。ただし測定は変更行のみで、
+ ワークフロー系など未解決プレースホルダの行は skip される
+- [ ] 認可を config の permission factory に委ねている場合、`None` で無効化していない
+ - CI: G4 が `*_PERMISSION_FACTORY` 等を監視
+
+### 機能クローズ・非公開化の場合 (Feature Disable)
+- [ ] UI(画面・ボタン)の非表示だけでなく、**バックエンド API(ルーティング・View)も完全に遮断**されている
+- [ ] 無効化状態で直接 API を叩いた場合、`404 Not Found` または `403 Forbidden` が返ることを確認した
+
+---
+
+## 🧪 2. テストコード観点チェック (pytest / Invenio Test Suite)
+
+### 権限・異常系テスト (Negative & Authorization Tests)
+- [ ] **未認証アクセス (Anonymous)**: トークン/セッションなしのリクエストで `401 Unauthorized` または `403 Forbidden` / `404 Not Found` が返ることを検証するテストがある
+- [ ] **権限不足ユーザー (Forbidden)**: 閲覧権限のみのユーザーが更新/削除 API を叩いた際に `403` になるテストがある
+- [ ] **無効化/非公開機能の遮断テスト**: 対象機能が無効化されている場合、エンドポイントが `404` / `403` を返すテストがある
+
+### 境界値・入力バリデーションテスト (Boundary & Validation)
+- [ ] 不正なパラメータ(巨大ファイル、異常な MIME タイプ、無効な JSON/XML スキーマ、SQLi/XSS ペイロード等)で適切に `400 Bad Request` / バリデーションエラーが返るテストがある
+
+### データ整合性・トランザクションテスト (Integrity & Rollback)
+- [ ] ファイルストレージ(S3/ローカル)書き込み失敗時や DB エラー時に、中途半端なレコードやゴミファイルが残らずロールバックされるテストがある
+
+---
+
+## 🛡️ 3. データ保護 & 破壊的変更防止チェック (Data Safety)
+
+- [ ] **物理削除・上書きの安全性**:
+ - ファイル・アイテム・メタデータの完全削除/置換処理に、意図しない一括削除や別レコードへの誤適用リスクがない
+ - 論理削除、バージョン管理、バックアップ等のロールバック機構が考慮されている
+- [ ] **トランザクション整合性**:
+ - DB 更新とストレージ操作がアトミックに管理されている
+
+---
+
+## ⚙️ 4. マイグレーション & システム影響チェック (Invenio / WEKO3 Stack)
+
+### データベース (DB / Alembic)
+- [ ] `invenio alembic upgrade`(適用)および `downgrade`(ロールバック)スクリプトを作成・検証した
+- [ ] 既存データに対する破壊的変更(カラム削除、型変更、NOT NULL 制約追加等)の移行スクリプト/データパッチを用意した
+
+### 検索インデックス (Elasticsearch / OpenSearch)
+- [ ] マッピング定義変更の有無を確認した
+- [ ] インデックス再作成(Reindex)やエイリアス切り替え手順を準備・検証した
+
+### 設定 & 非同期処理 (Config / Celery / Cache)
+- [ ] `invenio.cfg` / 環境変数のデフォルト値を設定した
+- [ ] Celery タスクのシグネチャ変更によるキュー滞留・不整合が発生しない
+- [ ] キャッシュ(Redis/Memcached)のパージが必要か確認した
+
+---
+
+## 📚 5. ドキュメント・仕様書更新チェック (weko-document)
+
+
+- [ ] **API インベントリ**: ツールは本リポジトリの `tools/api-inventory/`、
+ **台帳・調査記録は秘密の場所**(public リポジトリには置かない)。
+ §0 のチェック項目で対応済みなら、ここは確認のみ。
+ - エンドポイントの追加・変更・廃止、メソッド、認証・認可要件、リクエスト/レスポンス仕様を更新した
+ - 調査記録(`weko3_api_auth_findings.md`)も秘密側に置く。台帳は二重管理しない
+- [ ] **WEKO3 機能仕様書**:
+ - 対象機能の仕様追加・変更・クローズ(非公開化)内容を反映した
+- [ ] **各種マニュアル (管理者 / 利用者マニュアル)**:
+ - 画面導線・操作手順・権限仕様の変更を反映した
+- [ ] *更新不要な場合(理由)*:
+
+---
+
+## 📋 6. 動作検証エビデンス (Verification Evidence)
+
+### テスト実行結果
+```bash
+pytest tests/ -k <対象モジュール>
+# -> PASS
+```
+
+### CI の成果物 (artifact: `api-inventory-drift`)
+
+
+| ファイル | 内容 |
+|---|---|
+| `drift.md` | ベースラインとの差分(**件数のみ**) |
+| `reconcile.md` | 台帳と実機の突き合わせ(**件数のみ**) |
+
+明細(該当した経路名・実測結果)は公開できないため artifact に含めていない。
+秘密側で同じコマンドを `--summary-only` なしで実行して確認する。
+
+### 手動で確認したこと
+
+*
diff --git a/.github/workflows/api-inventory-drift.yml b/.github/workflows/api-inventory-drift.yml
new file mode 100644
index 0000000000..d4e4f8a7b4
--- /dev/null
+++ b/.github/workflows/api-inventory-drift.yml
@@ -0,0 +1,160 @@
+# WEKO3 リポジトリ(RCOSDP/weko)の .github/workflows/ に配置する。
+#
+# 【重要】このリポジトリは public。Actions のログ・artifact・PR コメントは誰でも読める。
+# したがって:
+# - 台帳(所見・実証結果を含む)とベースラインは **このリポジトリに置かない**。
+# 秘密のリポジトリから Secret 経由で取得する。
+# - 出力は --summary-only で **件数のみ**。URI や endpoint 名は出さない。
+# 明細は秘密側に置いたレポートで確認する。
+#
+# 必要な Secret:
+# secrets.API_INVENTORY_REPO 取得元の private リポジトリ (RCOSDP/weko-secret)
+# secrets.API_INVENTORY_SSH_KEY weko-secret に登録した read-only deploy key の秘密鍵
+# deploy key を使うのは、対象が1リポジトリに構造的に限定され、読み取り専用で、
+# 個人アカウントに紐づかないため(PAT より事故時の影響が小さい)。
+# 未設定なら、このジョブは何もせずスキップする(fork からの PR でも安全)。
+#
+# 設置手順: tools/api-inventory/ci/README.md
+
+name: API Inventory Drift
+
+on:
+ pull_request:
+ branches: ['**']
+ workflow_dispatch:
+
+jobs:
+ drift:
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ # fork からの PR には Secret が渡らない。無駄に起動しない。
+ if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # changed_rows.py が base..head の diff を取るため
+
+ - name: Check secrets
+ id: cfg
+ env:
+ REPO: ${{ secrets.API_INVENTORY_REPO }}
+ KEY: ${{ secrets.API_INVENTORY_SSH_KEY }}
+ run: |
+ if [ -n "$REPO" ] && [ -n "$KEY" ]; then
+ echo "enabled=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "enabled=false" >> "$GITHUB_OUTPUT"
+ echo "::notice::API_INVENTORY_REPO / API_INVENTORY_SSH_KEY が未設定のためスキップします"
+ fi
+
+ # 台帳・ベースラインを秘密リポジトリから取得する。
+ # チェックアウト先は .api-inventory-data/(.gitignore 済み)。
+ - name: Checkout inventory data (private)
+ if: steps.cfg.outputs.enabled == 'true'
+ uses: actions/checkout@v4
+ with:
+ repository: ${{ secrets.API_INVENTORY_REPO }}
+ ssh-key: ${{ secrets.API_INVENTORY_SSH_KEY }}
+ path: .api-inventory-data
+ persist-credentials: false
+
+ - uses: actions/setup-python@v5
+ if: steps.cfg.outputs.enabled == 'true'
+ with:
+ python-version: '3.11'
+
+ - name: Start WEKO containers
+ if: steps.cfg.outputs.enabled == 'true'
+ run: |
+ chmod +x install.sh
+ ./install.sh
+ env:
+ DOCKER_BUILDKIT: 1
+ COMPOSE_DOCKER_CLI_BUILD: 1
+
+ - name: Wait for web container
+ if: steps.cfg.outputs.enabled == 'true'
+ run: |
+ for i in $(seq 1 60); do
+ if docker compose -f docker-compose2.yml exec -T web \
+ bash -lc 'source ~/.virtualenvs/invenio/bin/activate; invenio --help' >/dev/null 2>&1; then
+ echo "ready"; exit 0
+ fi
+ sleep 10
+ done
+ docker compose -f docker-compose2.yml logs web | tail -100
+ exit 1
+
+ - name: Run drift checks
+ if: steps.cfg.outputs.enabled == 'true'
+ env:
+ WEKO_API_INVENTORY_DIR: ${{ github.workspace }}/.api-inventory-data
+ run: |
+ set -o pipefail
+ T=tools/api-inventory/scripts
+ # 実機 url_map からスナップショットを作る(生成物は公開領域に置かない)
+ python3 $T/snapshot.py --out /tmp/api_snapshot.new.json --profile default
+
+ # 以降はすべて --summary-only。件数だけを標準出力に出す。
+ python3 $T/diff_snapshot.py \
+ "$WEKO_API_INVENTORY_DIR/api_snapshot.json" /tmp/api_snapshot.new.json \
+ --summary-only --gate --out /tmp/drift.md
+
+ python3 $T/reconcile.py \
+ --snapshot /tmp/api_snapshot.new.json \
+ --summary-only --gate --out /tmp/reconcile.md
+
+ - name: Probe changed endpoints
+ if: always() && steps.cfg.outputs.enabled == 'true'
+ env:
+ WEKO_API_INVENTORY_DIR: ${{ github.workspace }}/.api-inventory-data
+ run: |
+ T=tools/api-inventory/scripts
+ # 変更が触れた台帳行を割り出す(no のみを出力。URIは出さない)
+ python3 $T/changed_rows.py \
+ "${{ github.event.pull_request.base.sha || github.event.before }}" "${{ github.sha }}" \
+ --out /tmp/rerun_nos.txt > /dev/null
+
+ # install.sh はレコードを作らないので最小コーパスを投入してから測る
+ python3 $T/fixtures.py --out /tmp/fixtures.json
+ python3 $T/probe_ci.py \
+ --fixtures /tmp/fixtures.json --only /tmp/rerun_nos.txt \
+ --allow-writes --summary-only --gate --out /tmp/probe.json
+
+ # artifact は件数のみのサマリに限定する(public なので誰でも取得できる)。
+ # drift.md / reconcile.md は --summary-only で生成済み。
+ # probe.json / api_snapshot.new.json は明細を含むため **上げない**。
+ - name: Upload summary (counts only)
+ if: always() && steps.cfg.outputs.enabled == 'true'
+ uses: actions/upload-artifact@v4
+ with:
+ name: api-inventory-summary
+ path: |
+ /tmp/drift.md
+ /tmp/reconcile.md
+
+ - name: Comment on PR (counts only)
+ if: always() && steps.cfg.outputs.enabled == 'true' && github.event_name == 'pull_request'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fs = require('fs');
+ const read = (p, title) => {
+ try { return `\n\n### ${title}\n\n` + fs.readFileSync(p, 'utf8'); }
+ catch (e) { return `\n\n### ${title}\n\n(生成されませんでした)`; }
+ };
+ let body = '## API インベントリ差分(件数のみ)\n\n'
+ + '> 明細は公開できないため件数のみ表示しています。'
+ + '該当箇所は秘密側の台帳・レポートで確認してください。';
+ body += read('/tmp/drift.md', 'ベースラインとの差分');
+ body += read('/tmp/reconcile.md', '台帳との突き合わせ');
+ await github.rest.issues.createComment({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ body: body.slice(0, 60000),
+ });
+
+ - name: Teardown
+ if: always() && steps.cfg.outputs.enabled == 'true'
+ run: docker compose -f docker-compose2.yml down -v
diff --git a/.gitignore b/.gitignore
index 963a08052c..555d20d263 100644
--- a/.gitignore
+++ b/.gitignore
@@ -104,3 +104,4 @@ test/dummyfile/data
# inbox
inbox/
ui-tests/test-results/
+/.api-inventory-data/
diff --git a/tools/api-inventory/.gitignore b/tools/api-inventory/.gitignore
new file mode 100644
index 0000000000..b2acacec07
--- /dev/null
+++ b/tools/api-inventory/.gitignore
@@ -0,0 +1,14 @@
+# このリポジトリは public。台帳・スナップショット・実測結果は一切置かない。
+# データは $WEKO_API_INVENTORY_DIR(秘密の場所)で管理する。
+weko3_api_list*.tsv
+weko3_api_list*_README.md
+weko3_api_auth_findings.md
+api_snapshot*.json
+reconcile_allow.json
+reconcile_report.md
+probe*.json
+drift*.md
+# fixtures.py が生成する。OAuthアクセストークンと平文パスワードを含む。
+fixtures.json
+__pycache__/
+*.pyc
diff --git a/tools/api-inventory/ci/README.md b/tools/api-inventory/ci/README.md
new file mode 100644
index 0000000000..7ac3030341
--- /dev/null
+++ b/tools/api-inventory/ci/README.md
@@ -0,0 +1,320 @@
+# 設置手順 — API インベントリ差分検知
+
+## 前提: このリポジトリは public
+
+`RCOSDP/weko` は public リポジトリで、**Actions のログ・artifact・PR コメントも誰でも読める**。
+台帳(`weko3_api_list_full.tsv`)は「どの経路を・どう叩けば・何が取れるか」と実証結果
+(`dynamic_verified` の ★)を持つため、**公開領域には一切置かない**。
+
+| 置き場所 | 内容 |
+|---|---|
+| **本リポジトリ `tools/api-inventory/`** | **ツールのみ**(scripts / ci)。データは1件も置かない |
+| **`RCOSDP/weko-secret`**(private) | 台帳TSV(57列/24列)、列定義README、`api_snapshot.json`、`reconcile_allow.json`、`reconcile_report.md`、調査記録 |
+
+スクリプトは環境変数 `WEKO_API_INVENTORY_DIR` で秘密の場所を指す。未設定なら理由を添えて中断する。
+
+```bash
+git clone https://github.com/RCOSDP/weko-secret.git
+export WEKO_API_INVENTORY_DIR=$PWD/weko-secret
+python3 tools/api-inventory/scripts/reconcile.py --gate
+```
+
+CI の出力は **`--summary-only` で件数のみ**。URI や endpoint 名は出さない。
+
+## 1. 移設するファイル
+
+WEKO3 リポジトリに `tools/api-inventory/` を作り、weko-document の
+`docs/spec/tools/api-inventory/` から以下を**移動**する。
+
+```
+weko/tools/api-inventory/ ← public。ツールのみ
+├── scripts/
+│ ├── README.md 全手順(Phase 1-7)
+│ ├── paths.py $WEKO_API_INVENTORY_DIR の解決
+│ ├── extract_routes.py … Phase 1-2: 静的抽出・観点付与
+│ ├── probe.py / asuser.sh Phase 3: 実機Docker実測(参考実装)
+│ ├── build_checklist.py Phase 5: 57列 → 24列の再生成
+│ ├── snapshot.py Phase 6: 実機url_map → スナップショット
+│ ├── diff_snapshot.py Phase 6: スナップショット間の差分 + ゲート
+│ ├── reconcile.py Phase 6: スナップショット ↔ 台帳の突き合わせ
+│ ├── changed_rows.py Phase 6: git差分 → 再レビュー対象行
+│ ├── fixtures.py Phase 7: 到達可否測定用の最小コーパス投入
+│ └── probe_ci.py Phase 7: フィクスチャ駆動の到達可否測定
+├── ci/
+│ ├── api-inventory-drift.yml
+│ └── README.md このファイル
+└── .gitignore データ類を誤ってコミットしないための保険
+
+$WEKO_API_INVENTORY_DIR/ ← 秘密の場所。public リポジトリには置かない
+├── weko3_api_list_full.tsv 台帳(57列・所見と実証結果つき)
+├── weko3_api_list.tsv 台帳(24列)
+├── weko3_api_list_README.md 24列の列定義・運用手順
+├── weko3_api_list_full_README.md 57列の列定義
+├── api_snapshot.json 経路のベースライン
+├── reconcile_allow.json 実機に無い行の許可リスト
+├── reconcile_report.md 突き合わせ結果
+└── weko3_api_auth_findings.md 調査記録
+```
+
+`.gitignore` で `*.tsv` / `api_snapshot*.json` / `reconcile_*` 等を無視しているが、
+これは保険であって設計ではない。**データを公開領域に置かないことが設計**。
+
+---
+
+---
+
+## 2. 導入の順序(順序依存があるので守ること)
+
+ベースライン `api_snapshot.json` が無いと `diff_snapshot.py` は動かない。
+また台帳と実機が一致していないと `reconcile.py` が即 FAIL する。
+**先にデータを入れ、最後にワークフローを有効化する。**
+
+```bash
+# --- 秘密の場所を用意する ---
+git clone https://github.com/RCOSDP/weko-secret.git
+export WEKO_API_INVENTORY_DIR=$PWD/weko-secret
+
+cd # WEKO3 リポジトリ(public)
+git switch -c chore/api-inventory-drift
+
+# 1) ツールだけを配置(データは置かない)
+mkdir -p tools/api-inventory
+# … scripts/ と ci/ を配置 …
+
+# 2) 実機を起動
+./install.sh
+
+# 3) ベースラインを現行リビジョンで生成し、**秘密の場所に**保存する
+python3 tools/api-inventory/scripts/snapshot.py \
+ --out "$WEKO_API_INVENTORY_DIR/api_snapshot.json"
+
+# 4) 台帳と一致することを確認(0 でなければ先に台帳を直す)
+python3 tools/api-inventory/scripts/reconcile.py --gate
+
+# 4b) 到達可否まで測るならフィクスチャを投入する
+python3 tools/api-inventory/scripts/fixtures.py --out /tmp/fixtures.json
+python3 tools/api-inventory/scripts/probe_ci.py --fixtures /tmp/fixtures.json --nos 34,925,25
+
+# 5) ワークフローを配置
+cp tools/api-inventory/ci/api-inventory-drift.yml .github/workflows/
+
+# 6) GitHub に Secret を登録する
+# API_INVENTORY_REPO = RCOSDP/weko-secret
+# API_INVENTORY_SSH_KEY = weko-secret の read-only deploy key の秘密鍵
+# 作り方:
+# ssh-keygen -t ed25519 -N '' -C 'api-inventory-ci' -f /tmp/k
+# gh api repos/RCOSDP/weko-secret/keys -X POST -f title='api-inventory-ci' \
+# -f key="$(cat /tmp/k.pub)" -F read_only=true
+# gh secret set API_INVENTORY_SSH_KEY --repo RCOSDP/weko < /tmp/k
+# shred -u /tmp/k /tmp/k.pub
+# 未設定ならワークフローは何もせずスキップする
+
+git add -A && git commit -m "chore(ci): API インベントリ差分検知を追加"
+```
+
+**秘密の場所には何も commit しないこと。** `git status` で `tools/api-inventory/` 配下に
+`*.tsv` や `api_snapshot.json` が現れたら、置き場所を間違えている。
+
+3 のベースラインは**そのブランチのリビジョンで取り直す**こと。
+weko-document に置いてあるものは `d2fdc0e3b`(v2.0.3) 時点なので、
+導入先のブランチが進んでいれば差分が出る。
+
+---
+
+## 3. ベースラインの更新ルール(これを決めないと形骸化する)
+
+**API を変更した PR では、秘密側の `api_snapshot.json` を更新する。**
+公開リポジトリのコード変更と、秘密側のベースライン更新は**別の PR になる**。
+案C(データを公開領域に置かない)の代償で、ここだけは手順が2つに分かれる。
+
+```bash
+# API を変更した PR の作業ブランチで
+./install.sh
+python3 tools/api-inventory/scripts/snapshot.py \
+ --out "$WEKO_API_INVENTORY_DIR/api_snapshot.json"
+# → 秘密リポジトリ側で commit / PR を作る
+```
+
+差分は**秘密リポジトリの `git diff`** に出る。「どの経路が増えたか・認証がどう変わったか」を
+レビュアの目に入れる仕組みは維持されるが、見る場所が秘密側になる。
+公開リポジトリの CI は件数だけを報告し、詳細は出さない。
+
+なお PR の CI は「PR ブランチの実機」と「PR ブランチのベースライン」を比べるため、
+ベースラインを更新すると差分は 0 になる。**変更の妥当性を見るのは CI ではなくレビュア**で、
+CI の役割は「ベースラインを更新せずに API を変えること」を防ぐことにある。
+
+---
+
+## 3b. ベースラインは CI と同じ環境で作る
+
+`api_snapshot.json` は `meta.packages` にインストール済みパッケージの版を持ち、
+`diff_snapshot.py` の W6 がその差を検知する。**ベースラインを CI と違う環境で作ると、
+毎回 W6 が出続けて警告が形骸化する。**
+
+実測: 手元の docker 環境で作ったベースライン(302パッケージ)を、CI の
+`install.sh --no-cache` で作った環境(301パッケージ)と比べると W6 が2件出た。
+経路(endpoints=860 / AST結合=495 / 属性不明=365)は完全に一致していたので、
+差は依存の版だけ。
+
+対処: **ベースラインは `install.sh` で作った環境から生成する**。
+
+```bash
+./install.sh # CI と同じ手順で作り直す
+python3 tools/api-inventory/scripts/snapshot.py \
+ --out "$WEKO_API_INVENTORY_DIR/api_snapshot.json"
+```
+
+W6 は WARN なのでゲートは通るが、放置すると本当の依存更新に気づけなくなる。
+
+---
+
+## 3c. WEKO3 のバージョンごとにタグを打つ
+
+台帳は「どのリビジョンの WEKO3 に対する調査結果か」が分からないと意味を失う。
+`api_snapshot.json` の `meta.revision` / `meta.tag` に生成元は記録されるが、
+**秘密リポジトリ側にも同名のタグを打って対応を固定する**。
+
+```bash
+cd "$WEKO_API_INVENTORY_DIR"
+git tag -a v2.0.3 -m "WEKO3 v2.0.3 (RCOSDP/weko d2fdc0e3b) 時点の API インベントリ
+
+対象: RCOSDP/weko d2fdc0e3b62479a362d9ace6216316630ad654a6 (tag v2.0.3)
+台帳: 926行 / 経路 URI 870 / 実機との突き合わせ差分 0"
+git push origin v2.0.3
+```
+
+タグ名は **WEKO3 側のタグと同じ**にする(`v2.0.3` なら `v2.0.3`)。
+メッセージには対象コミットの完全な SHA と、その時点の台帳規模・突き合わせ結果を残す。
+
+### バージョンアップ時の流れ
+
+1. WEKO3 の新バージョンで `install.sh` → `snapshot.py` でベースラインを作り直す
+2. `reconcile.py` の差分を 0 にする(新規経路を台帳に追加、消えた経路を整理)
+3. `changed_rows.py` が出す行を Phase 2-3 で再確認する
+4. 秘密リポジトリを commit し、**WEKO3 と同名のタグを打つ**
+
+タグを打たずに台帳だけ更新すると、過去のバージョンに対する調査結果を後から
+参照できなくなる(インシデント調査や監査で「その時点でどうだったか」を問われる)。
+
+---
+
+## 4. ゲートが FAIL したときの対処
+
+| ゲート | 意味 | 対処 |
+|---|---|---|
+| G1 新規に認証デコレータが無い | 認証の付け忘れの可能性 | 意図的な公開なら台帳に根拠を書いたうえでベースライン更新。そうでなければ実装を直す |
+| G2 認証デコレータが削除された | | 同上 |
+| G3 認証のコメントアウトが増えた | no.34(IIIF)と同型 | 原則やり直し。残すなら理由をコード中のコメントに明記 |
+| G4 config が危険側に変わった | no.10/519/520/920 と同型 | 原則やり直し |
+| G5 ModelView の can_delete/can_export 有効化 | 削除・全件CSV出力面が開く | 意図的なら台帳の data_op を更新 |
+| G6 属性不明の経路が増えた | 外部ライブラリ由来など | 台帳に行を追加してから `reconcile.py` を通す |
+| G7 依存更新で経路が増減 | Flask-IIIF 等 | 増えた経路を台帳に追加。減った場合は該当行を削除するか allow に登録 |
+| G8 未認証で到達する書き込み系 | 認可が効いていない | 原則やり直し。意図的な公開なら台帳の根拠を更新 |
+| G9 台帳では遮断だが実測で到達 | 認可の回帰 | 原則やり直し |
+| reconcile A(未収載) | 台帳に無い経路がある | `weko3_api_list_full.tsv` に行を追加 → `build_checklist.py` で24列版を再生成 |
+| reconcile B(実機に無い) | 台帳にあるが登録されていない | 理由を確認し `reconcile_allow.json` に**理由付きで**登録。理由なしの登録は禁止 |
+| reconcile C/D | メソッド・app列の記載誤り | 台帳を実機に合わせる |
+
+「とりあえず allow に入れて通す」を防ぐため、`reconcile_allow.json` は
+**理由の文字列が必須**(キーの値が説明文になっている)。レビューで理由を読むこと。
+
+---
+
+## 5. プロファイル(条件付き blueprint 登録への対応)
+
+`weko-notifications/ext.py:41` や `invenio-accounts/ext.py:168` のように
+config で blueprint 登録を分岐している箇所があるため、**1プロファイルのダンプでは
+他の設定で有効になる経路を見落とす**。
+
+CI は既定プロファイル(`--profile default`)のみを回す。全機能を有効にした
+プロファイルを追加する場合は、ベースラインをプロファイルごとに持つ。
+
+```bash
+python3 tools/api-inventory/scripts/snapshot.py \
+ --out tools/api-inventory/api_snapshot.full.json --profile full-features
+```
+
+比較は**同一プロファイル同士**で行うこと。異なる場合は `diff_snapshot.py` が
+レポート冒頭で警告する(条件付き登録の差が追加/削除として現れるため)。
+
+---
+
+## 6. トラブルシュート
+
+**`docker cp 失敗: must specify at least one container source`**
+`--container` に空文字が渡っている。原因はほぼ **compose のプロジェクト名の食い違い**。
+
+`docker compose -f X.yml ps -q web` は **X.yml のプロジェクト名**でしか探さない。
+`install.sh` は `docker-compose2.yml`(project=`wekov2`)を使うが、
+手動で `docker compose -p weko up -d web` のように起動したスタックは project=`weko` なので
+0 件になる。実行中のコンテナがどのプロジェクトに属するかは次で分かる。
+
+```bash
+docker inspect weko-web-1 \
+ --format '{{index .Config.Labels "com.docker.compose.project"}} / {{index .Config.Labels "com.docker.compose.project.config_files"}}'
+```
+
+**対処: `--container` を省略する。** `snapshot.py` は
+`com.docker.compose.service=web` ラベルから起動中のコンテナを自動検出する
+(0件・複数件なら候補を挙げて中断する)。明示したい場合は `--container weko-web-1`。
+
+**`install.sh` が失敗している場合**
+`docker compose -f docker-compose2.yml logs web` を見る。
+
+**`url_map ダンプ失敗` で `invenio shell` のトレースバックが出る**
+`invenio shell -c` の中で例外が起きている。よくあるのは ModelView の `can_*` が
+権限を実行時評価する property になっているケース(`invenio_communities/admin.py:571` の
+`can_create` が `min()` で `ValueError`)。`snapshot.py` の `safe()` で握っているが、
+新しい ModelView で同種の例外が出たら同様に握る。
+
+**`create_api()` を2回呼んで `KeyError: 'schemas_route'`**
+`weko-schema-ui/rest.py:127` が `options.pop()` するため API アプリは2回作れない。
+`snapshot.py` は `current_app.wsgi_app.mounts['/api']` を辿ることでこれを回避している。
+自前でダンプを書くときは `create_api()` を再度呼ばないこと。
+
+**`reconcile.py` が大量の C(メソッド不一致)を出す**
+メソッドを endpoint 単位で union していないか確認する。同じ `view_func` を
+`add_url_rule` で複数回登録すると endpoint 名が同一になるため、
+ルール単位(`routes: [{rule, methods}]`)で比較しなければならない。
+
+**probe の結果が「判定不能」ばかりになる**
+フィクスチャが入っていない可能性が高い。`fixtures.py` を先に流すこと。
+投入済みでも、合成レコードはアイテムタイプ固有フィールドを持たないため
+詳細画面のレンダリングは 404/500 になりうる。ワークフローの activity も
+未整備なので no.601-636 は「未解決プレースホルダ」で skip される。これは既知の限界。
+
+**fixtures.json をコミットしそうになる**
+OAuthアクセストークンと平文パスワードを含むため `.gitignore` に入れてある。
+CI では毎回生成する。
+
+**中間ファイルがリポジトリに残る**
+`snapshot.py` の中間ファイル(`_dump.py` / `_snapshot_dump.json`)は既定で一時ディレクトリに
+作られ、終了時に削除される。`--workdir` を明示したときだけそこに残る(デバッグ用)。
+
+**外部ライブラリ由来の経路が大量に unknown になる**
+仕様。860経路のうち291件(34%)は `modules/` に無いライブラリの登録で、
+AST では属性を取れない。G6 で人のレビューに回すのが設計意図。
+
+---
+
+## 7. CI を入れない場合の手動運用
+
+CI を入れずに、リリース前の棚卸しだけ機械化することもできる。
+
+```bash
+./install.sh
+WEB=$(docker compose -f docker-compose2.yml ps -q web)
+python3 tools/api-inventory/scripts/snapshot.py --out /tmp/new.json --container "$WEB"
+python3 tools/api-inventory/scripts/diff_snapshot.py \
+ tools/api-inventory/api_snapshot.json /tmp/new.json --out drift.md
+python3 tools/api-inventory/scripts/reconcile.py --snapshot /tmp/new.json --out reconcile.md
+python3 tools/api-inventory/scripts/changed_rows.py <前回タグ> HEAD --out rerun_nos.txt
+
+# 到達可否まで測る場合(使い捨て環境でのみ --allow-writes)
+python3 tools/api-inventory/scripts/fixtures.py --out /tmp/fixtures.json
+python3 tools/api-inventory/scripts/probe_ci.py --fixtures /tmp/fixtures.json \
+ --only rerun_nos.txt --allow-writes --out probe.json
+```
+
+ただし「ベースラインを更新せずに API を変えること」は防げないため、
+検知が漏れるのはリリース直前になる。
diff --git a/tools/api-inventory/ci/api-inventory-drift.yml b/tools/api-inventory/ci/api-inventory-drift.yml
new file mode 100644
index 0000000000..208c34649c
--- /dev/null
+++ b/tools/api-inventory/ci/api-inventory-drift.yml
@@ -0,0 +1,160 @@
+# WEKO3 リポジトリ(RCOSDP/weko)の .github/workflows/ に配置する。
+#
+# 【重要】このリポジトリは public。Actions のログ・artifact・PR コメントは誰でも読める。
+# したがって:
+# - 台帳(所見・実証結果を含む)とベースラインは **このリポジトリに置かない**。
+# 秘密のリポジトリから Secret 経由で取得する。
+# - 出力は --summary-only で **件数のみ**。URI や endpoint 名は出さない。
+# 明細は秘密側に置いたレポートで確認する。
+#
+# 必要な Secret:
+# secrets.API_INVENTORY_REPO 取得元の private リポジトリ (RCOSDP/weko-secret)
+# secrets.API_INVENTORY_SSH_KEY weko-secret に登録した read-only deploy key の秘密鍵
+# deploy key を使うのは、対象が1リポジトリに構造的に限定され、読み取り専用で、
+# 個人アカウントに紐づかないため(PAT より事故時の影響が小さい)。
+# 未設定なら、このジョブは何もせずスキップする(fork からの PR でも安全)。
+#
+# 設置手順: tools/api-inventory/ci/README.md
+
+name: API Inventory Drift
+
+on:
+ pull_request:
+ branches: ['**']
+ workflow_dispatch:
+
+jobs:
+ drift:
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ # fork からの PR には Secret が渡らない。無駄に起動しない。
+ if: github.event.pull_request.head.repo.full_name == github.repository
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # changed_rows.py が base..head の diff を取るため
+
+ - name: Check secrets
+ id: cfg
+ env:
+ REPO: ${{ secrets.API_INVENTORY_REPO }}
+ KEY: ${{ secrets.API_INVENTORY_SSH_KEY }}
+ run: |
+ if [ -n "$REPO" ] && [ -n "$KEY" ]; then
+ echo "enabled=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "enabled=false" >> "$GITHUB_OUTPUT"
+ echo "::notice::API_INVENTORY_REPO / API_INVENTORY_SSH_KEY が未設定のためスキップします"
+ fi
+
+ # 台帳・ベースラインを秘密リポジトリから取得する。
+ # チェックアウト先は .api-inventory-data/(.gitignore 済み)。
+ - name: Checkout inventory data (private)
+ if: steps.cfg.outputs.enabled == 'true'
+ uses: actions/checkout@v4
+ with:
+ repository: ${{ secrets.API_INVENTORY_REPO }}
+ ssh-key: ${{ secrets.API_INVENTORY_SSH_KEY }}
+ path: .api-inventory-data
+ persist-credentials: false
+
+ - uses: actions/setup-python@v5
+ if: steps.cfg.outputs.enabled == 'true'
+ with:
+ python-version: '3.11'
+
+ - name: Start WEKO containers
+ if: steps.cfg.outputs.enabled == 'true'
+ run: |
+ chmod +x install.sh
+ ./install.sh
+ env:
+ DOCKER_BUILDKIT: 1
+ COMPOSE_DOCKER_CLI_BUILD: 1
+
+ - name: Wait for web container
+ if: steps.cfg.outputs.enabled == 'true'
+ run: |
+ for i in $(seq 1 60); do
+ if docker compose -f docker-compose2.yml exec -T web \
+ bash -lc 'source ~/.virtualenvs/invenio/bin/activate; invenio --help' >/dev/null 2>&1; then
+ echo "ready"; exit 0
+ fi
+ sleep 10
+ done
+ docker compose -f docker-compose2.yml logs web | tail -100
+ exit 1
+
+ - name: Run drift checks
+ if: steps.cfg.outputs.enabled == 'true'
+ env:
+ WEKO_API_INVENTORY_DIR: ${{ github.workspace }}/.api-inventory-data
+ run: |
+ set -o pipefail
+ T=tools/api-inventory/scripts
+ # 実機 url_map からスナップショットを作る(生成物は公開領域に置かない)
+ python3 $T/snapshot.py --out /tmp/api_snapshot.new.json --profile default
+
+ # 以降はすべて --summary-only。件数だけを標準出力に出す。
+ python3 $T/diff_snapshot.py \
+ "$WEKO_API_INVENTORY_DIR/api_snapshot.json" /tmp/api_snapshot.new.json \
+ --summary-only --gate --out /tmp/drift.md
+
+ python3 $T/reconcile.py \
+ --snapshot /tmp/api_snapshot.new.json \
+ --summary-only --gate --out /tmp/reconcile.md
+
+ - name: Probe changed endpoints
+ if: always() && steps.cfg.outputs.enabled == 'true'
+ env:
+ WEKO_API_INVENTORY_DIR: ${{ github.workspace }}/.api-inventory-data
+ run: |
+ T=tools/api-inventory/scripts
+ # 変更が触れた台帳行を割り出す(no のみを出力。URIは出さない)
+ python3 $T/changed_rows.py \
+ "${{ github.event.pull_request.base.sha }}" "${{ github.sha }}" \
+ --out /tmp/rerun_nos.txt > /dev/null
+
+ # install.sh はレコードを作らないので最小コーパスを投入してから測る
+ python3 $T/fixtures.py --out /tmp/fixtures.json
+ python3 $T/probe_ci.py \
+ --fixtures /tmp/fixtures.json --only /tmp/rerun_nos.txt \
+ --allow-writes --summary-only --gate --out /tmp/probe.json
+
+ # artifact は件数のみのサマリに限定する(public なので誰でも取得できる)。
+ # drift.md / reconcile.md は --summary-only で生成済み。
+ # probe.json / api_snapshot.new.json は明細を含むため **上げない**。
+ - name: Upload summary (counts only)
+ if: always() && steps.cfg.outputs.enabled == 'true'
+ uses: actions/upload-artifact@v4
+ with:
+ name: api-inventory-summary
+ path: |
+ /tmp/drift.md
+ /tmp/reconcile.md
+
+ - name: Comment on PR (counts only)
+ if: always() && steps.cfg.outputs.enabled == 'true' && github.event_name == 'pull_request'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fs = require('fs');
+ const read = (p, title) => {
+ try { return `\n\n### ${title}\n\n` + fs.readFileSync(p, 'utf8'); }
+ catch (e) { return `\n\n### ${title}\n\n(生成されませんでした)`; }
+ };
+ let body = '## API インベントリ差分(件数のみ)\n\n'
+ + '> 明細は公開できないため件数のみ表示しています。'
+ + '該当箇所は秘密側の台帳・レポートで確認してください。';
+ body += read('/tmp/drift.md', 'ベースラインとの差分');
+ body += read('/tmp/reconcile.md', '台帳との突き合わせ');
+ await github.rest.issues.createComment({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ body: body.slice(0, 60000),
+ });
+
+ - name: Teardown
+ if: always() && steps.cfg.outputs.enabled == 'true'
+ run: docker compose -f docker-compose2.yml down -v
diff --git a/tools/api-inventory/scripts/README.md b/tools/api-inventory/scripts/README.md
new file mode 100644
index 0000000000..454b494300
--- /dev/null
+++ b/tools/api-inventory/scripts/README.md
@@ -0,0 +1,694 @@
+# WEKO3 API インベントリ/セキュリティチェックリスト 作成手順
+
+> **【重要】このリポジトリは public。データを置かないこと。**
+> 台帳(`weko3_api_list*.tsv`)は「どの経路を・どう叩けば・何が取れるか」と実証結果を
+> 持つため、公開領域には置かない。ツールは環境変数で秘密の場所を参照する。
+>
+> ```bash
+> export WEKO_API_INVENTORY_DIR=/path/to/api-inventory-private
+> ```
+>
+> CI の出力は `--summary-only` で件数のみ(Actions のログ・artifact・PR コメントは
+> 誰でも読めるため)。置き場所と設置手順は `../ci/README.md` を参照。
+
+
+
+> **重要(このディレクトリの配置について)**
+> 本ツール群は成果物とともに `weko-document/docs/spec/tools/api-inventory/` に配置されている。
+> 解析対象は **WEKO3 本体リポジトリ**(`/home/mhaya/wekov2` 等)なので、実行時は解析対象を明示すること:
+> ```bash
+> WEKO_ROOT=/home/mhaya/wekov2 python3 scripts/extract_routes.py routes.json
+> ```
+> 成果物TSV/MDは一つ上の階層(`../weko3_api_list.tsv` 等)にある。
+
+
+`weko3_api_list.tsv`(24列・チェックリスト版)と `weko3_api_list_full.tsv`(57列・詳細版)を
+**バージョンアップのたびに再生成**するための手順とスクリプト一式。
+
+# 台帳の更新手順(まずここを読む)
+
+Phase 1-9 は「どう作るか」。日々の更新はこの節だけで足りる。
+
+## 前提
+
+```bash
+git clone https://github.com/RCOSDP/weko-secret.git
+export WEKO_API_INVENTORY_DIR=$PWD/weko-secret
+cd /path/to/weko # ツールは WEKO3 リポジトリ側にある
+```
+
+## 大原則
+
+- **`weko3_api_list.tsv`(24列版)は直接編集しない。** `weko3_api_list_full.tsv` から
+ `build_checklist.py` が丸ごと生成する派生物で、手を入れても次の生成で消える。
+- **派生列も手編集しない。** `priority` / `priority_reason` / `test_normal`〜`test_gap` /
+ `cleanup` はスクリプトが毎回上書きする。直したいときは判定の入力側
+ (`security_finding` / `dynamic_verified` / `data_op` / `deprecated` 等)を直すか、
+ `prioritize.py` のルールを変える。
+- **実行順がある。** `prioritize.py` は `test_gap` を参照するので `test_coverage.py` が先。
+
+## ケース1: 派生列を再計算するだけ(最も多い)
+
+判定ルールを変えた、テストを追加した、といったとき。
+
+```bash
+python3 tools/api-inventory/scripts/test_coverage.py # テスト4観点を判定
+python3 tools/api-inventory/scripts/prioritize.py # 優先度・整理対象を付与
+python3 tools/api-inventory/scripts/build_checklist.py # 24列版を再生成
+```
+
+## ケース2: 台帳に行を追加・修正する
+
+`reconcile.py` が「A. インベントリ未収載」を出したときなど。
+
+```bash
+# 1) full.tsv を直接編集(65列。列数を合わせること)
+vi "$WEKO_API_INVENTORY_DIR/weko3_api_list_full.tsv"
+
+# 2) 列数の検算
+awk -F'\t' 'NR>1 && NF!=65{print "行"NR" 列数="NF}' \
+ "$WEKO_API_INVENTORY_DIR/weko3_api_list_full.tsv"
+
+# 3) 派生列を再計算 → 24列版を再生成
+python3 tools/api-inventory/scripts/test_coverage.py
+python3 tools/api-inventory/scripts/prioritize.py
+python3 tools/api-inventory/scripts/build_checklist.py
+
+# 4) 実機と一致するか確認(差分0になること)
+python3 tools/api-inventory/scripts/reconcile.py --gate
+```
+
+## ケース3: WEKO3 のバージョンアップに伴う全面更新
+
+```bash
+./install.sh # CI と同じ環境で作り直す
+python3 tools/api-inventory/scripts/snapshot.py \
+ --out "$WEKO_API_INVENTORY_DIR/api_snapshot.json" # ベースライン再生成
+
+python3 tools/api-inventory/scripts/reconcile.py --gate # 差分を0にする(台帳の追加・削除)
+python3 tools/api-inventory/scripts/changed_rows.py <前回タグ> HEAD --out /tmp/rerun.txt
+# → 出た no を Phase 2-3 で再確認し full.tsv を更新
+
+python3 tools/api-inventory/scripts/test_coverage.py
+python3 tools/api-inventory/scripts/prioritize.py
+python3 tools/api-inventory/scripts/build_checklist.py
+```
+
+最後に **WEKO3 と同名のタグ**を打つ(理由は `../ci/README.md` 3c)。
+
+```bash
+cd "$WEKO_API_INVENTORY_DIR"
+git add -A && git commit -m "..."
+git tag -a v2.0.4 -m "WEKO3 v2.0.4 (RCOSDP/weko ) 時点の API インベントリ"
+git push origin main --follow-tags
+```
+
+## 各スクリプトが何を読み書きするか
+
+| スクリプト | 読む | 書く |
+|---|---|---|
+| `snapshot.py` | 実機 url_map + ソース | `api_snapshot.json` |
+| `reconcile.py` | snapshot + full.tsv | 何も書かない(差分を報告するだけ) |
+| `changed_rows.py` | git diff + full.tsv | 再確認対象の `no` 一覧 |
+| `test_coverage.py` | full.tsv + テストコード | full.tsv の 60-64列 |
+| `prioritize.py` | full.tsv | full.tsv の 58-59, 65列 + 末尾列順の正規化 |
+| `build_checklist.py` | full.tsv | **`weko3_api_list.tsv` を全体再生成** |
+
+`test_coverage.py` → `prioritize.py` → `build_checklist.py` は**何度流しても結果が変わらない**
+(冪等)。24列版は full.tsv から完全に再現できることを確認済み。
+
+---
+
+## 全体の考え方
+
+3層で構築する:
+1. **静的抽出** — ソースコードから全HTTPエンドポイントと属性を機械抽出(AST)
+2. **静的解析** — 認証/認可/データ操作/セキュリティ観点を実装読解+AST解析で付与
+3. **動的検証** — 実際にDocker起動しHTTPリクエストを送って机上の結論を実測で裏取り
+
+「デコレータの有無」だけでなく **実測での到達可否** まで取ることで、
+「認可が実際に効いているか」を確定できる(静的解析だけでは分からない)。
+
+---
+
+## Phase 0: 対象リビジョンの確定
+```bash
+cd /home/mhaya/wekov2
+git rev-parse --short HEAD # 生成元リビジョンを記録
+git describe --tags # タグ
+```
+
+## Phase 1: 静的抽出(エンドポイント発見)
+
+### 1-1. blueprint route を AST 抽出
+```bash
+python3 tools/api-inventory/extract_routes.py routes.json
+```
+`@blueprint.route` / `add_url_rule` を全 modules から収集。357件程度。
+
+### 1-2. config駆動 REST エンドポイントを抽出
+```bash
+python3 tools/api-inventory/extract_endpoints.py endpoints.json
+```
+`*_REST_ENDPOINTS` config の route 文字列(`//...`)を展開。
+
+### 1-3. 実機の url_map と突合(取りこぼし検出) ★重要
+静的抽出は Flask-Admin ModelView(自動生成CRUD 253件)や framework 由来ルートを取りこぼす。
+**実際にアプリを起動して url_map をダンプし、差分を追記する**(Phase 3 で起動後):
+```bash
+# コンテナ内で
+docker exec weko-web-1 bash -lc 'source ~/.virtualenvs/invenio/bin/activate; cd /code; \
+ invenio shell -c "from flask import current_app; \
+ [print(r.endpoint, sorted(r.methods), str(r)) for r in current_app.url_map.iter_rules()]"'
+```
+これと Phase1-1/1-2 の抽出結果を endpoint 名で照合し、未収載を追記する。
+※ @route/@expose だけを見ると ModelView と framework(security.login等)が漏れる。
+
+### URI 算出ルール(重要)
+- `invenio_base.blueprints`/`apps` 経由 → `bp.url_prefix` + route
+- `invenio_base.api_blueprints`/`api_apps` 経由 → **`/api`** + `bp.url_prefix` + route
+- Flask-Admin → `/admin//<@expose path>`
+- 根拠: `invenio_app.factory` が API アプリを DispatcherMiddleware で `/api` にマウント
+
+## Phase 2: 静的解析(観点の付与)
+
+各エンドポイントの実装関数(+呼び出しヘルパ1段)を AST で解析し、列を付与。
+
+| スクリプト | 付与する列 |
+|---|---|
+| (extract_routes内) | summary/response/exceptions/data操作の一次抽出 |
+| `audit_decorators.py` | コメントアウトされた認証/デコレータ不揃い/roles_required無効化 |
+| `audit_injection.py` | eval/exec, ZIP-slip, SQLi連結, パストラバーサル |
+| `add_cols.py` | csrf_protection, input_validation, audit_logged, triggers_task, resource_limit |
+| `add_ssrf_redirect.py` | redirect_target(オープンリダイレクト), ssrf_surface |
+| `add_idempotency.py` | idempotency(冪等性) |
+| `add_dataop4.py` | data_op_detail(取得/作成/更新/**論理削除/物理削除**) |
+| `add_authmech.py` | auth_mechanism(decorator/config-factory/modelview), bola_risk |
+
+### 認証・認可の参照辞書(手動で維持)
+- ロール: System/Repository/Community Administrator, Contributor, General
+- スコープ: `*/scopes.py`(item:read, file:read, index:*, author:*, oa_status:update等)
+- permission factory: `weko-records-ui/permissions.py`(page/file_permission_factory)
+- `WEKO_ADMIN_ACCESS_TABLE`(weko-admin/config.py) = Flask-Admin のロール制御
+
+### git情報の付与
+```bash
+python3 tools/api-inventory/enrich_git.py body.tsv body_enriched.tsv
+```
+`git log -L <開始>,<終了>:` で**実装関数の行範囲**の最終コミットを取得(ファイル単位より正確)。
+`git tag --sort=creatordate --contains ` で導入リリースタグ。
+
+## Phase 3: 動的検証(実測で裏取り) ★静的だけでは不正確
+
+### 3-1. Docker 環境起動
+```bash
+# 既存の初期化済みボリュームを再利用(プロジェクト名 weko)
+docker compose -p weko up -d postgresql pgpool redis elasticsearch rabbitmq
+docker compose -p weko up -d web
+# egg-info がソースと食い違うと起動失敗 → 全モジュール再生成
+docker exec -u root weko-web-1 bash -lc 'source ~/.virtualenvs/invenio/bin/activate; cd /code; \
+ for d in modules/weko-* modules/invenio-*; do (cd "$d" && python setup.py egg_info -q); done'
+docker compose -p weko restart web
+# nginx(80/443競合時は remap して起動、web は uwsgi プロトコルで直叩き不可)
+docker compose -p weko -f docker-compose.yml -f nginx-override.yml up -d nginx
+# → https://localhost:8443 (Host: weko3.example.org)
+```
+
+### 3-2. テストデータ・アカウント準備
+- 全ユーザにパスワード設定 → `/api/v1/login` でロール別セッションCookie取得
+- 公開/非公開アイテム、グループ・コミュニティ(所有者を変えて)、OAuthトークン(全スコープ)
+- ファイル実体(ObjectVersion)を非公開アイテムに添付
+
+### 3-3. 全フラグ付きエンドポイントを実測
+```bash
+python3 tools/api-inventory/probe.py probe_results.json # 未認証+各ロールで叩く
+```
+- プレースホルダ(``等)を実値に解決
+- ★**Cookie失効に注意**: セッションは短時間で失効する。identityごとに直前ログイン+
+ sentinel(既知の200エンドポイント)で鮮度確認してから測定する(`reprobe_own.py`/`asuser.sh`方式)。
+ 失効Cookieは認証ユーザに大量の偽「遮断」を生む。
+- ★**500の切り分け**: `security.login`のBuildErrorなら login_required による遮断、
+ それ以外は認可通過後のクラッシュ=到達。ログで個別判定する。
+
+### 3-4. 実測結果を dynamic_verified 列へ
+`apply_probe.py`/`final_apply.py` で判定(未認証で到達/ログインのみ/管理者のみ/遮断/検証不能)を付与。
+本文取得・DB改変・ファイル露出まで確認できたものは `★確定` とする。
+
+## Phase 4: マージ・整形
+```bash
+python3 tools/api-inventory/merge.py out/ merged.tsv # 分割TSVを結合・重複排除・採番
+```
+
+## Phase 5: チェックリスト版(24列)を生成
+```bash
+python3 tools/api-inventory/build_checklist.py # 57列 full → 24列 に統合
+```
+派生列を統合: impl(func+file+line), auth(required+method+mechanism),
+security_flags(CSRF/BOLA/SSRF等8観点を該当のみ), last_change(commit系4列) 等。
+
+---
+
+## 観点の網羅性(OWASP API Security Top 10 対応)
+| OWASP API(2023) | 対応列 |
+|---|---|
+| API1 BOLA | bola_risk / security_finding:所有者チェック欠落 |
+| API2 Broken Auth | auth / dynamic_verified |
+| API3 Property-Level Auth | access_variance |
+| API4 Resource Consumption | security_flags:RESLIMIT |
+| API5 Function-Level Auth | roles_scope / security_finding:権限過小 |
+| API6 Business Flow | security_flags:IDEMP |
+| API7 SSRF | security_flags:SSRF |
+| API8 Misconfiguration | security_flags:CSRF / config_deps |
+| API9 Inventory Mgmt | deprecated / api_version / test_file |
+| API10 Unsafe Consumption | side_effects |
+
+## 差分レビューの勘所(バージョンアップ時)
+1. Phase1で新url_mapを取り、前回の `no`/`uri` と**差分**を取る(新規/削除エンドポイント)
+2. 新規・変更行だけ Phase2-3 を回す(全部再測定は不要)
+3. `auth`が`不要`(公開)に変わった行、`security_flags`に★が付いた行を重点確認
+4. `data_op`が`物理削除`(不可逆)の新規エンドポイントは特に注意
+
+## 既知の限界
+- SSRF検出は関数本体+1段ヘルパまで。route→Celery→utils の間接SSRFは triggers_task で追跡。
+- ModelView 253件は代表実測。全個別測定ではない。
+- 動的検証はテストデータ依存。完全な end-to-end(ワークフロー経由の正規deposit)は一部のみ。
+
+---
+
+# Phase 6: 差分検知(バージョンアップ時の機械的チェック)
+
+Phase 1-5 が「作る」手順なのに対し、Phase 6 は **「変わったことを検知する」** 手順。
+CI に組み込んで、API の追加・仕様変更を人手のレビュー前に機械で拾う。
+
+## なぜ実機 url_map が正なのか
+
+**ブループリント(`@bp.route`)を見ても半分しか分からない。** 実測値:
+
+```
+AST抽出(@bp.route + add_url_rule) : 357ルート / 77ブループリント
+実機url_map(static除く) : 903ルート (UI 724 + API 248)
+ASTで説明できない : 472件 (52%)
+```
+
+漏れの内訳:
+
+| 種別 | 件数 | 理由 |
+|---|---:|---|
+| Flask-Admin ModelView 自動生成 | 223 | `index_view`/`create_view`/`edit_view`/`delete_view`/`details_view`/`action_view`/`ajax_lookup`/`ajax_update`。ModelView を1つ定義すると8ルート生える |
+| `@expose`(Flask-Admin BaseView) | 約100 | `@bp.route` ではないので route 抽出の対象外。リポジトリ内に `@expose` 205箇所 |
+| config駆動 REST(`*_REST_ENDPOINTS`) | 約30 | route 文字列が config の dict の中 |
+| `modules/` に無い pip パッケージ | — | `extract_routes.py` は `ROOT/modules` しか walk しない |
+| route が式の `add_url_rule` | — | 357件中75件が add_url_rule、`options.pop('rdc_route')` 等は literal_eval 不可 |
+| framework 由来 | — | flask_security / flask-oauthlib(9) / invenio_i18n |
+
+したがって **役割を分ける**:
+
+- **経路の集合(何が存在するか)** → 実機 url_map ダンプが唯一の正
+- **各経路の属性(誰が叩けるか)** → AST + ソース読解
+
+## 6-1. スナップショット生成
+
+```bash
+python3 scripts/snapshot.py --out api_snapshot.json --container weko-web-1
+# → endpoints=860 (AST結合=495 / 属性不明=365) modelviews=30 config=33
+```
+
+やっていること:
+
+1. 実機 url_map を **UIアプリと APIアプリの両方**ダンプ
+ (APIアプリは `current_app.wsgi_app.mounts['/api']` を辿らないと出てこない)
+2. ModelView の権限属性(`can_delete`/`can_export`/`column_export_list`)を併せて取得
+ — url_map には現れないが、`can_export` が有効化されると DB 全件 CSV 出力面が開く
+3. AST で全 def を索引化し、`(module, funcname)` で url_map に左結合してデコレータを付与
+4. **結合できなかったものは `attrs: "unknown"` として残す**(黙って落とさない)
+
+出力の構造:
+
+```jsonc
+{
+ "meta": { "revision": "d2fdc0e3b", "tag": "v2.0.3", "profile": "default", "counts": {...} },
+ "endpoints": { "api:weko_admin.get_curr_api_cert": {
+ "rules": ["/admin/get_curr_api_cert/"],
+ "methods": ["GET"],
+ "auth_decorators": [], // ← 認証デコレータ無し
+ "auth_hash": "da39a3ee5e6b",
+ "body_hash": "…" } },
+ "modelviews": { "actionroles": { "can_delete": true, "can_export": false, … } },
+ "config": { "…/config.py::RECORDS_REST_DEFAULT_UPDATE_PERMISSION_FACTORY": {…} },
+ "commented_auth": { "invenio_iiif.handlers": [{ "line": 39, "text": "#g.obj = ObjectResource.get_object(…)" }] }
+}
+```
+
+キーは **`:`**。URL ではなく Flask の endpoint 名にすることで、
+URL だけ変わった場合を「新規+削除」ではなく `RULE_CHANGED` と正しく分類できる。
+1エンドポイントが複数ルールを持つ場合(末尾スラッシュ違い等・36件)は `rules` 配列で保持する。
+
+### プロファイル
+
+条件付きで blueprint を登録している箇所が実在する
+(`weko-notifications/ext.py:41`, `invenio-accounts/ext.py:168` 等)ため、
+**1プロファイルのダンプでは他の設定で有効になる経路を見落とす。**
+
+```bash
+python3 scripts/snapshot.py --out api_snapshot.json --profile default
+python3 scripts/snapshot.py --out api_snapshot.full.json --profile full-features
+```
+
+比較は同一プロファイル同士で行う(異なる場合は差分レポートが警告する)。
+
+### 外部ライブラリが登録する経路(動的抽出でしか見えないもの)
+
+実機ダンプの最大の効き目はここ。**860経路のうち 291件(34%)は `modules/` に存在しない
+外部ライブラリが登録している。**
+
+| provider | 経路数 | 例 |
+|---|---:|---|
+| Flask-Admin==1.5.4 | 254 | `/admin/actionroles/action/` |
+| invenio-records-ui==1.0.0 | 20 | `/item/edit/` |
+| invenio-oauthclient==1.0.0 | 5 | `/oauth/authorized//` |
+| Flask-Security==3.0.0 | 4 | `/confirm/` |
+| **Flask-IIIF==0.6.1** | **3** | `/iiif//////.` |
+| invenio-i18n / invenio-jsonschemas / invenio-csl-rest | 5 | `/lang/`, `/schema/`, `/csl/styles` |
+
+**no.34(非公開ファイルの実体を未認証で取得できることを実証した経路)は Flask-IIIF が
+登録している。** リポジトリのソースをいくら走査しても出てこない。動的抽出が必須である
+最も強い実例。
+
+各エンドポイントには `provider: "<配布物>==<版>"` を付与し、`meta.packages` に
+インストール済み302パッケージの版を丸ごと保持する。これにより
+**「依存を上げたら経路が増えた」を機械的に帰着できる**(ゲート G7)。
+
+```jsonc
+"api:iiifimageapi": {
+ "rules": ["/iiif////…"],
+ "view": "flask_iiif.restful.iiifimageapi",
+ "provider": "Flask-IIIF==0.6.1",
+ "attrs": "unknown", "reason": "framework 由来"
+}
+```
+
+## 6-2. 差分とゲート
+
+```bash
+python3 scripts/diff_snapshot.py OLD.json NEW.json --out drift.md --gate
+# FAIL があれば exit 1
+```
+
+分類:
+
+| 分類 | 意味 |
+|---|---|
+| `ADDED` / `REMOVED` | 経路の増減 → **インベントリへの追加/削除が必要** |
+| `RULE_CHANGED` | endpoint 同一で URL が変化 |
+| `METHODS_CHANGED` | HTTPメソッドの増減 |
+| `AUTH_CHANGED` | 認証・認可デコレータの変化(最優先) |
+| `IMPL_CHANGED` | デコレータ据置きで実装本体のみ変化 |
+| `ATTRS_UNKNOWN_NEW` | 経路はあるが静的解析で属性が取れない新規 |
+
+`IMPL_CHANGED` は「デコレータは同じだが中身が変わった」を拾う。
+no.480(`page_permission_factory` が `flg='Edit'` を無視)のような
+**ロジック内認可**の変化はここでしか捕まらない。
+
+### ゲート(いずれも過去の実際の穴から導出)
+
+| ID | 条件 | 由来 |
+|---|---|---|
+| G1 | 新規エンドポイントに認証系デコレータが無い | no.200/201/389/390/393 |
+| G2 | 認証系デコレータが削除された | — |
+| G3 | 認証/認可デコレータのコメントアウトが増えた | **no.34(IIIF `protect_api`)** |
+| G4 | 認可を左右する config が危険側に変わった | **no.10/269/271/519/520(`factory=None`)** |
+| G5 | ModelView の `can_delete`/`can_export` が False→True | CSV export 22件 |
+| G6 | 属性不明のまま追加された経路がある | 手動レビュー必須 |
+| G7 | 依存パッケージの更新で外部ライブラリ由来の経路が増減した | **no.34(Flask-IIIF)** |
+| W1 | ModelView が追加された | 1つにつき自動生成8ルート(削除系を含む) |
+| W2 | 実装本体が変化 | data_op / 情報露出の再確認 |
+| W6 | 依存パッケージの版が変化した | 経路据置きでも既存経路の挙動が変わりうる |
+
+G3 は **エンドポイントに紐付かないコメントアウトも検知する**。
+no.34 の `protect_api` はビュー関数ではなくハンドラフックなので、
+エンドポイント単位の検査だけでは捕まらない。
+
+## 6-3. 再レビュー対象行の絞り込み
+
+```bash
+python3 scripts/changed_rows.py v2.0.2 v2.0.3 --out rerun_nos.txt
+# v2.0.2..v2.0.3
+# 変更ファイル(modules/*.py): 9
+# 再レビュー対象行: 1 / 全918行
+# no=21 GET /admin/location/ modules/invenio-files-rest/invenio_files_rest/admin.py:178
+```
+
+`git diff -U0` の変更行を AST で def/class 範囲に広げ、
+インベントリの `impl_file`/`impl_line` と突き合わせる(`enrich_git.py` と同じ関数単位の考え方)。
+918行すべてを Phase2-3 に回す必要がなくなる。
+
+`views.py`/`rest.py`/`admin.py`/`ext.py`/`config.py` が変更されたのに
+インベントリに未登録のファイルは「新規エンドポイントの可能性」として別途警告する。
+
+## 6-4. CI への組み込み
+
+**設置手順は `ci/README.md`**(移設するファイル・導入順序・ベースライン更新ルール・
+ゲートFAIL時の対処・プロファイル・トラブルシュート)。ワークフロー本体は
+`ci/api-inventory-drift.yml`。
+
+CI が触るファイルは WEKO3 リポジトリの `tools/api-inventory/` に移設して
+**単一リポジトリで完結**させる。別リポジトリの checkout もトークンも不要。
+
+WEKO3 側には `ui-tests.yml` が既にあり、**push/PR ごとに `install.sh` で
+WEKO スタック全体を起動している**。実機 url_map を取る土台は既に存在するので、
+ジョブを1つ足すだけでよい。
+
+**`api_snapshot.json` を git 管理するのが肝。**
+API を変えた PR は必ずスナップショット更新を伴い、**差分がコードレビューに乗る**。
+人手の運用ルールではなく、diff が目に入る仕組みになる。
+
+## 6-5. さらに強くしたい場合
+
+**アクセスログからの実在経路収集。** nginx のアクセスログから `(method, パステンプレート)`
+の distinct を取り、インベントリと突き合わせる。プラグインや動的登録で
+**コードにも url_map スナップショットにも出ない経路**が本番で叩かれていないかの最終確認になる。
+(インベントリに「経路なし(プラグイン未登録)」と記録した4件は、逆に本番では有効な可能性がある)
+
+## 6-6. インベントリとの突き合わせ(reconcile)
+
+スナップショットは「実機に何があるか」、インベントリTSVは「調査済みの台帳」。
+**この2つがズレていないかを機械的に検証する**のが `reconcile.py`。
+
+```bash
+python3 scripts/reconcile.py --gate --out reconcile_report.md
+# A=0 B=0 C=0 D=0 B'(既知)=11 → exit 0
+```
+
+| 検出 | 意味 |
+|---|---|
+| A. インベントリ未収載 | 実機にあるが台帳に無い = **抽出漏れ** |
+| B. 実機に無いインベントリ行 | 台帳にあるが url_map に無い(未登録/条件付き) |
+| C. メソッド不一致 | 同一URIでHTTPメソッドが食い違う |
+| D. app列の不一致 | UI/API どちらに登録されているかの記載誤り |
+
+B のうち正当な理由があるもの(プラグイン未登録・config で無効・動的登録のプレースホルダ)は
+`reconcile_allow.json` に**理由付きで**登録して既知扱いにする。理由なしの登録は禁止。
+
+### URI の正規化規則(ここを間違えると偽の差分が大量に出る)
+
+- **スナップショット**: APIアプリのルールには `/api` を前置する。
+ APIアプリは DispatcherMiddleware で `/api` にマウントされるため、その url_map 側には prefix が出ない。
+- **インベントリ**: `uri` セルの `;` 区切りを展開する。`app=両方` の行は `/api` 側も展開する。
+- **末尾スラッシュ**は除去して比較する。
+- **HEAD / OPTIONS** は比較対象外。werkzeug が GET ルールに自動付与するため、
+ 実装が HEAD を意識しているかを区別できない。
+
+### メソッドは必ずルール単位で比較する
+
+同じ `view_func` を `add_url_rule` で複数回登録すると **endpoint 名が同一になる**。
+このとき endpoint 単位でメソッドを union すると、実際には POST しか受けないルールが
+`DELETE,GET,POST,PUT` を受けるように見えてしまう(初版で偽の不一致17件を出した)。
+
+```python
+# weko-index-tree/rest.py:217-232 — 同じ view_func `ima` を別ルール・別メソッドで登録
+blueprint.add_url_rule(options.get('api_create_index'), view_func=ima, methods=['POST'])
+blueprint.add_url_rule(options.get('api_update_index'), view_func=ima, methods=['PUT'])
+blueprint.add_url_rule(options.get('api_delete_index'), view_func=ima, methods=['DELETE'])
+```
+
+このため `snapshot.py` は `routes: [{rule, methods}, …]` とルール単位で保持する
+(`rules` / `methods` は概観用の派生値)。
+
+### CI での位置づけ
+
+`diff_snapshot.py`(前回スナップショットとの差分)と `reconcile.py`(台帳との差分)は目的が違う。
+両方を回す:
+
+- `diff_snapshot.py` … **バージョン間**で何が変わったか
+- `reconcile.py` … **今の実機と台帳**が一致しているか(=調査漏れが無いか)
+
+---
+
+# Phase 7: 到達可否の実測を CI に載せる
+
+Phase 6 は**構造の変化**(経路・デコレータ・config)を検知するが、
+`dynamic_verified`(誰が到達できるか)は更新しない。新規APIが増えても
+「未認証で本当に到達するか」は測られず、認証を追加して直しても
+「修正が効いているか」を確認できない。Phase 7 がそこを埋める。
+
+## 7-0. なぜフィクスチャが要るか
+
+`install.sh` は `scripts/populate-instance.sh:179` の
+
+```bash
+#${INVENIO_WEB_INSTANCE} demo init ← コメントアウトされている
+```
+
+によって **レコードを1件も作らない**。CI 環境で入るのは次のとおり。
+
+| 項目 | CI環境 |
+|---|---|
+| ロール4種 + action 付与 / ユーザ5人 | あり |
+| アイテムタイプ / インデックスツリー / ワークフロー定義 / ファイルロケーション | あり |
+| **recid / depid のレコード** | **0件** |
+| **ファイル実体(ObjectVersion)** | **なし** |
+| **公開/非公開の区別、他人所有のリソース** | **なし** |
+| **OAuthトークン / Community / Group** | **なし** |
+
+この状態では認可判定を通せず、到達可否を測れない。
+
+## 7-1. `fixtures.py` — 最小テストコーパスの投入
+
+```bash
+python3 scripts/fixtures.py --out fixtures.json
+# users=5 records=3 index=900001 file=あり token=あり community=あり group=あり
+```
+
+投入するもの:
+
+- 既知パスワード(`Passw0rd!123`)に揃えたユーザ5人
+- 公開インデックス(`900001`)
+- レコード3件 — いずれもバケット付き
+ - `public` (recid 900001, publish_status=0, owner=Contributor)
+ - `private` (recid 900002, publish_status=1, owner=Contributor, **ファイル実体付き**)
+ - `other_owner` (recid 900003, publish_status=1, owner=General, **ファイル実体付き**)
+- 全19スコープの個人アクセストークン
+- Community / Group
+
+**冪等かつ自己修復。** 既存があれば再利用しつつ `path` / `owner` / `publish_status` を
+毎回入れ直す。先行ステップ(インデックス作成など)が失敗した回に作られたレコードは
+`path` が空のままになり `check_index_permissions` を通らないため、再実行で直るようにしてある。
+
+生成物 `fixtures.json` は **`.gitignore` 済み**。OAuthアクセストークンと平文パスワードを
+含むのでリポジトリには入れない。CI では毎回生成する。
+
+### フィクスチャで再現できること(検証済み)
+
+```
+未認証 IIIF info.json → 200 (no.925)
+未認証 IIIF 画像本体 → 200 / 70バイト (no.34)
+未認証 files-rest 直 → 404 (露出がIIIF経路限定であることも再現)
+Contributor → 他人所有のファイル → 200 / 70バイト (no.25 の BOLA)
+未認証 POST /api/deposits/items → 200 (no.920)
+```
+
+### 限界(正直に)
+
+合成レコードのためアイテムタイプ固有フィールドを持たない。詳細画面の
+レンダリングは 404/500 になりうる。**ワークフロー経由の正規 deposit は作っていない。**
+`probe_ci.py` はこれを「判定不能」として明示するので、誤った安心にはならないが、
+レンダリングまで通す必要がある行は測れない。ワークフローの activity も未整備のため
+no.601-636 は未解決プレースホルダとして skip される。
+
+## 7-2. `probe_ci.py` — フィクスチャ駆動の実測
+
+```bash
+python3 scripts/probe_ci.py --only rerun_nos.txt --allow-writes --gate --out probe.json
+```
+
+`probe.py`(参考実装)はセッション固有のUUIDとパスがハードコードされている。
+`probe_ci.py` は `fixtures.json` からプレースホルダを解決するため、まっさらな環境で動く。
+
+- 測定 identity: anon / general / contributor / comadmin / repoadmin / sysadmin
+- 測定対象は `--only` で渡した `no` に限定する(全926行を毎PR測ると時間がかかりすぎる)
+- **安全装置**: GET/HEAD 以外は既定でスキップ。`--allow-writes` を明示したときだけ測る
+ (CI のコンテナは使い捨てなので許可してよいが、実環境では既定のままにすること)
+
+### 判定の切り分け
+
+| 応答 | 判定 | 根拠 |
+|---|---|---|
+| 401 / 403 | 遮断 | |
+| 3xx でログイン画面へ | 遮断 | |
+| **3xx でログイン画面以外** | **到達** | no.480 は未認証 302 で `publish_status` が実際に書き換わる。302を一律「遮断」にすると取りこぼす |
+| 500 (本文に `security.login` / `BuildError`) | 遮断 | APIアプリの `login_required` は BuildError で 500 になる |
+| 500 (それ以外) | 到達 | 認可通過後のクラッシュ |
+| 404 | 判定不能 | `hidden=True` の権限NGか、対象が無いだけか区別できない |
+| 2xx / 400 / 405 / 415 | 到達 | |
+
+### アイテムIDは公開/非公開の両方で測る
+
+`` 等は `public` と `private` の**両方**に解決して2回測る。
+どちらを入れるかで結論が変わるため(no.480 は非公開だとログインへ転送されるが、
+公開アイテムでは未認証で書き換えが成立することを実証済み)。
+
+`` は文脈依存で、IIIF なら `v2`、WEKO の REST API なら `v1` に解決する。
+
+## 7-3. ゲート
+
+| ID | 条件 |
+|---|---|
+| **G8** | 未認証で到達し、かつ `data_op` が作成/更新/削除 |
+| **G9** | 台帳が「遮断」なのに実測で「到達」(回帰) |
+
+CI では `changed_rows.py` が出す `rerun_nos.txt`(変更が触れた行)だけを測る。
+全件測定はリリース前の棚卸しで行う。
+
+---
+
+# Phase 8: テスト観点の解析と対応優先度の付与
+
+```bash
+python3 scripts/test_coverage.py # 4観点(正常値/異常値/境界値/例外処理)を判定
+python3 scripts/prioritize.py # 優先度を付与(テスト観点を参照するので後に実行)
+python3 scripts/build_checklist.py # 24列版(=32列)へ引き継ぐ
+```
+
+**実行順が重要**: `prioritize.py` は `test_gap` を参照して「テスト観点が確認できない行」を
+P2 まで引き上げるため、`test_coverage.py` を先に回すこと。
+
+## test_coverage.py
+
+`test_file`(対応テスト) と `impl_func` を突き合わせ、そのエンドポイントに関係する
+テスト関数を特定してから観点を判定する。ファイル単位で見ると、同じファイル内の
+別APIのテストを自分のものとして数えてしまうため。
+
+対応するテスト関数を同定できなかった行は `特定不能` として区別する。
+**「テストが無い」と断定はできない**ので、4観点すべて欠落と同じ表記にはしない。
+
+## prioritize.py
+
+`security_finding` / `security_flags` / `dynamic_verified` / `data_op` / `data_target` /
+`method` / `auth` / `test_gap` から、対応優先度を機械判定して台帳に書き戻す。
+判定基準・凡例・限界は秘密側の `weko3_api_list_README.md`「priority の凡例」に記載。
+
+「データ破壊」は **既存の実データを不可逆に壊すこと** と定義している。メタデータの
+更新や新規作成は含めない。新規作成しかせず既存データを壊さないものは、認証が
+無くても P0 ではなく P1 に置く。
+
+参照系でも、露出内容が **認証情報** または **非公開データの実体** であり認可が
+緩い行は P1 に上げる。「読み取り系だから限定的」は露出物次第で成り立たないため。
+
+`deprecated` の記述から **非利用** を判定し、認可上の判定が P2 以下なら
+`整理対象` に置き換える(削除すれば認可の問題ごと消える)。
+
+`reconcile_allow.json` に登録された URI(=実機の url_map に無いことを確認済み)は
+`環境依存` とする。**`整理対象` とは別物で、削除してはいけない**。別の設定・別サイトでは
+有効になるため。判定に `dynamic_verified` を使わないのは、実測欄の粒度がばらついていて
+同じ機能群が別区分に割れるため(プラグイン群8件が対象外3件と整理対象5件に割れていた)。
+
+いずれも P0/P1 の行は優先度を落とさず、理由に事情を添えるだけにする。
+
+末尾の派生列(priority / test_* / cleanup)の並びは `prioritize.py` が正規化するため、
+実行順に依存しない。
+
+**この判定は着手順を決めるための粗い仕分けであって、リスク評価の代替ではない。**
+`method` ベースで判定するため副作用のある GET を落とすこと、読み取り系は露出内容の
+重大さ(認証情報か公開情報か)を見ていないこと、テスト観点は静的なキーワード判定で
+あって内容の妥当性を見ていないこと──いずれも目視補正が要る。
+
diff --git a/tools/api-inventory/scripts/add_authmech.py b/tools/api-inventory/scripts/add_authmech.py
new file mode 100644
index 0000000000..c0fb8fcc28
--- /dev/null
+++ b/tools/api-inventory/scripts/add_authmech.py
@@ -0,0 +1,83 @@
+# -*- coding: utf-8 -*-
+"""auth_mechanism(認証の付け方3分類) と bola_risk(object-level認可の有無) を付与"""
+import ast,re,os
+R="/home/mhaya/wekov2/"
+def load(p): return [l.rstrip("\n").split("\t") for l in open(p,encoding="utf-8") if l.rstrip("\n")]
+rows=load(R+"weko3_api_list.tsv"); hd=rows[0]; data=rows[1:]
+
+filecache={}
+def get_file(fp):
+ if fp in filecache: return filecache[fp]
+ full=os.path.join(R,fp)
+ if not os.path.isfile(full): filecache[fp]=(None,None); return (None,None)
+ try:
+ txt=open(full,encoding="utf-8",errors="replace").read(); tree=ast.parse(txt)
+ except: filecache[fp]=(None,None); return (None,None)
+ lines=txt.splitlines(); funcs={}
+ for n in ast.walk(tree):
+ if isinstance(n,(ast.FunctionDef,ast.AsyncFunctionDef)):
+ funcs[n.name]=(n.lineno,getattr(n,"end_lineno",n.lineno))
+ filecache[fp]=(lines,funcs); return (lines,funcs)
+def func_src(fp,ln):
+ lines,funcs=get_file(fp)
+ if not lines: return ""
+ best=None
+ for name,(s,e) in funcs.items():
+ if s<=int(ln)<=e and (best is None or (e-s)<(best[2]-best[1])): best=(name,s,e)
+ if not best: return ""
+ seg="\n".join(lines[best[1]-1:best[2]])
+ called=set(re.findall(r"\b([a-z_][a-z0-9_]*)\s*\(", seg))
+ for cn in called:
+ if cn in funcs and cn!=best[0]:
+ s,e=funcs[cn]
+ if e-s<150: seg+="\n"+"\n".join(lines[s-1:e])
+ return seg
+
+# --- auth_mechanism: この行の認証はどこで定義されるか ---
+def col_mech(c):
+ at=c[2] # api_type
+ am=c[21] # auth_method
+ if "ModelView" in at: return "modelview(Flask-Admin is_accessible/role_has_access)"
+ if at=="フレームワーク": return "framework(invenio/flask-security既定)"
+ # config駆動REST判定: uriにやREST系、blueprintが*_rest
+ bp=c[10]
+ if bp.endswith("_rest") or bp.endswith("_rest2") or "REST" in c[11] or "_options" in c[11]:
+ return "config-factory(*_REST_ENDPOINTS permission_factory_imp)"
+ if am=="admin-role-table": return "modelview/admin(role_has_access)"
+ if am in("none","rate-limit-only","不要") or c[20]=="不要": return "none(デコレータ無し・公開)"
+ if "action-need" in am: return "decorator(@x_permission.require)"
+ if "record-permission" in am: return "decorator(@need_record_permission)"
+ if "files-action" in am: return "decorator(@need_permissions)+ActionRole(グローバル付与注意)"
+ if "oauth" in am: return "decorator(@require_api_auth/@require_oauth_scopes)"
+ if "session" in am: return "decorator(@login_required系)"
+ if "custom" in am: return "decorator(@check_authority等カスタム)"
+ return "decorator(その他)"
+
+# --- bola_risk: object-level認可(所有者/対象単位チェック)が実装にあるか ---
+OWNER=re.compile(r"created_by|check_created_id|owner|current_user\.(id|get_id)|weko_shared|can_edit|is_himself|has_permission|check_authority|activity_login_user|check_index_permission|permission_factory|need_record_permission|get_or_404|filter_by\([^)]*user")
+def col_bola(c,seg):
+ m=(c[4] or "GET").split(",")[0]
+ # パスにリソースID(<...pid/id/recid...>)があるか
+ has_id=bool(re.search(r"<[^>]*(pid_value|recid|id|identifier|bucket_id|activity_id|group_id|key)", c[5]))
+ if not has_id: return "N/A(リソースID無し)"
+ if "ModelView" in c[2]: return "admin-role-tableのみ(オブジェクト単位判定なし=管理者は全件)"
+ if OWNER.search(seg): return "object-level認可あり(所有者/対象単位)"
+ # sec_patternに所有者チェック欠落があれば実証済み
+ if len(c)>41 and "所有者チェック欠落" in c[41]: return "★object-level認可なし(BOLA・実証済)"
+ return "★object-level認可なし(要確認・ID直指定で他リソース操作の懸念)"
+
+mech_c=collections.Counter() if False else {}
+nb=0
+import collections
+mc=collections.Counter(); bc=collections.Counter()
+for c in data:
+ seg=func_src(c[13],c[14]) if (len(c)>14 and str(c[14]).isdigit() and c[14]!="0") else ""
+ mech=col_mech(c); bola=col_bola(c,seg)
+ c += [mech,bola]
+ mc[mech.split("(")[0]]+=1; bc[bola.split("(")[0]]+=1
+ if "★" in bola: nb+=1
+open(R+"weko3_api_list.tsv","w",encoding="utf-8").write("\t".join(hd+["auth_mechanism","bola_risk"])+"\n"+
+ "\n".join("\t".join(str(x).replace("\t"," ") for x in c) for c in data)+"\n")
+print("=== auth_mechanism 分布 ==="); [print(f" {n:4d} {k}") for k,n in mc.most_common()]
+print("=== bola_risk 分布 ==="); [print(f" {n:4d} {k}") for k,n in bc.most_common()]
+print("列数:",len(hd)+2)
diff --git a/tools/api-inventory/scripts/add_cols.py b/tools/api-inventory/scripts/add_cols.py
new file mode 100644
index 0000000000..f7bd8aced6
--- /dev/null
+++ b/tools/api-inventory/scripts/add_cols.py
@@ -0,0 +1,81 @@
+# -*- coding: utf-8 -*-
+"""3観点列(csrf_protection/input_validation/audit_logged/triggers_task/resource_limit)をAST+実装から機械付与"""
+import ast,re,os,collections
+R="/home/mhaya/wekov2/"
+def load(p): return [l.rstrip("\n").split("\t") for l in open(p,encoding="utf-8") if l.rstrip("\n")]
+rows=load(R+"weko3_api_list.tsv"); hd=rows[0]; data=rows[1:]
+
+# 実装関数のソース断片をキャッシュ
+srccache={}
+def get_src(fp,ln):
+ key=(fp,ln)
+ if key in srccache: return srccache[key]
+ full=os.path.join(R,fp)
+ if not os.path.isfile(full): srccache[key]=""; return ""
+ try:
+ lines=open(full,encoding="utf-8",errors="replace").read().splitlines()
+ tree=ast.parse("\n".join(lines))
+ except Exception:
+ srccache[key]=""; return ""
+ best=None
+ for n in ast.walk(tree):
+ if isinstance(n,(ast.FunctionDef,ast.AsyncFunctionDef)):
+ s=n.lineno; e=getattr(n,"end_lineno",n.lineno)
+ if s<=int(ln)<=e and (best is None or (e-s)<(best[1]-best[0])):
+ best=(s,e)
+ seg="\n".join(lines[best[0]-1:best[1]]) if best else "\n".join(lines[max(0,int(ln)-1):int(ln)+30])
+ srccache[key]=seg; return seg
+
+def col_csrf(c,seg):
+ m=c[4];
+ if not re.search(r"POST|PUT|DELETE|PATCH",m): return "N/A(参照系)"
+ if "csrf_random" in seg or "validate_csrf" in seg: return "手動csrf照合あり"
+ # このアプリはCSRFProtect未初期化 → session認証の状態変更は無防備
+ if c[21] in("session","session+guest") or "session" in c[21] or c[20]=="要":
+ if "oauth" in c[21] or "Bearer" in seg or "require_api_auth" in seg: return "OAuth(CSRF非該当)"
+ return "★CSRF保護なし(CSRFProtect未初期化・状態変更)"
+ return "CSRF該当外(未認証public)"
+
+def col_input(seg):
+ has_schema = bool(re.search(r"\bSchema\(\)\.load|marshmallow|\.validate\(|use_kwargs|use_args", seg))
+ raw_json = bool(re.search(r"get_json\(|request\.json|request\.form\.get|request\.values\.get|request\.data", seg))
+ force = "force=True" in seg
+ extract = "extractall(" in seg
+ pathjoin = bool(re.search(r"os\.path\.join\([^)]*request|PyFSFileStorage\(|\.save\(", seg)) and "secure_filename" not in seg
+ tags=[]
+ if extract: tags.append("ZIP展開(slip検証要確認)")
+ if pathjoin: tags.append("パス連結(secure_filename無)")
+ if has_schema: tags.append("Schema検証あり")
+ elif raw_json: tags.append("生入力(スキーマ検証なし"+("・force=True" if force else "")+")")
+ return ";".join(tags) if tags else "-"
+
+def col_audit(seg):
+ m=re.findall(r"UserActivityLogger\.\w+\(\s*operation\s*=\s*[\"']?(\w+)", seg)
+ if m: return "記録あり:"+",".join(sorted(set(m)))
+ if "UserActivityLogger" in seg: return "記録あり(operation動的)"
+ return "-"
+
+def col_task(seg):
+ t=re.findall(r"(\w+)\.(?:delay|apply_async|s)\(", seg)
+ return ";".join(sorted(set(t))) if t else "-"
+
+def col_reslimit(seg):
+ if re.search(r"size\s*=\s*10000|WEKO_SEARCH_MAX_RESULT|max_result_window", seg): return "size=10000(全走査懸念)"
+ if re.search(r"\.scan\(|scan_iter|for .* in .*all\(\)", seg): return "全走査/scan"
+ return "-"
+
+newcols=["csrf_protection","input_validation","audit_logged","triggers_task","resource_limit"]
+for c in data:
+ seg=get_src(c[13],c[14]) if c[14].isdigit() else ""
+ c += [col_csrf(c,seg), col_input(seg), col_audit(seg), col_task(seg), col_reslimit(seg)]
+open(R+"weko3_api_list.tsv","w",encoding="utf-8").write("\t".join(hd+newcols)+"\n"+
+ "\n".join("\t".join(x.replace("\t"," ") for x in c) for c in data)+"\n")
+# サマリ
+for i,name in enumerate(newcols):
+ ci=len(hd)+i
+ cnt=collections.Counter()
+ for c in data:
+ v=c[ci] if len(c)>ci else "-"
+ cnt["有" if v not in("-","N/A(参照系)","CSRF該当外(未認証public)","OAuth(CSRF非該当)") else "無/N-A"]+=1
+ print(f"{name}: 有効値={cnt['有']}")
+print("列数:",len(hd)+len(newcols))
diff --git a/tools/api-inventory/scripts/add_dataop4.py b/tools/api-inventory/scripts/add_dataop4.py
new file mode 100644
index 0000000000..9a92ee4e49
--- /dev/null
+++ b/tools/api-inventory/scripts/add_dataop4.py
@@ -0,0 +1,80 @@
+# -*- coding: utf-8 -*-
+"""data_op_detail列: 取得/作成/更新/物理削除/論理削除 を実装から4区分評価"""
+import ast,re,os
+R="/home/mhaya/wekov2/"
+def load(p): return [l.rstrip("\n").split("\t") for l in open(p,encoding="utf-8") if l.rstrip("\n")]
+rows=load(R+"weko3_api_list.tsv"); hd=rows[0]; data=rows[1:]
+
+# ファイル全体をキャッシュし、関数本体+同ファイル内で呼ぶヘルパも1段追う
+filecache={}
+def get_file(fp):
+ if fp in filecache: return filecache[fp]
+ full=os.path.join(R,fp)
+ if not os.path.isfile(full): filecache[fp]=(None,None); return (None,None)
+ try:
+ txt=open(full,encoding="utf-8",errors="replace").read(); tree=ast.parse(txt)
+ except: filecache[fp]=(None,None); return (None,None)
+ lines=txt.splitlines()
+ funcs={}
+ for n in ast.walk(tree):
+ if isinstance(n,(ast.FunctionDef,ast.AsyncFunctionDef)):
+ funcs[n.name]=(n.lineno,getattr(n,"end_lineno",n.lineno))
+ filecache[fp]=(lines,funcs); return (lines,funcs)
+
+def func_src(fp,ln):
+ lines,funcs=get_file(fp)
+ if not lines: return ""
+ best=None
+ for name,(s,e) in funcs.items():
+ if s<=int(ln)<=e and (best is None or (e-s)<(best[2]-best[1])): best=(name,s,e)
+ if not best: return ""
+ seg="\n".join(lines[best[1]-1:best[2]])
+ # 呼び出すヘルパ(同ファイル定義)を1段展開
+ called=set(re.findall(r"\b([a-z_][a-z0-9_]*)\s*\(", seg))
+ for cn in called:
+ if cn in funcs and cn!=best[0]:
+ s,e=funcs[cn];
+ if e-s<200: seg+="\n"+"\n".join(lines[s-1:e])
+ return seg
+
+# パターン
+PHYS=re.compile(r"db\.session\.delete\(|\.query\b[^\n]*\.delete\(\)|session\.execute\([^\n]*delete|os\.remove\(|shutil\.rmtree|storage\.delete\(|file_storage\.delete\(|\.remove\(\)|bucket\.remove|ObjectVersion[^\n]*\.remove")
+LOGIC=re.compile(r"is_deleted\s*=\s*True|soft_delete|PIDStatus\.DELETED|\.delete\(\)\s*#?\s*soft|status\s*=\s*['\"]?D|delete_flag\s*=\s*True|mark.*deleted|logical")
+CREATE=re.compile(r"\.create\(|db\.session\.add\(|\binsert\b|\.append\(.*db|new_|Create")
+UPDATE=re.compile(r"\.update\(|db\.session\.merge\(|setattr\(|\.commit\(\)[^\n]*update|= request\.(form|json|values)")
+READ=re.compile(r"\.get\(|\.query\b|\.filter|search|\.first\(\)|\.all\(\)|jsonify\(")
+
+def eval4(c,seg,method):
+ ops=[]
+ if READ.search(seg) or method in("GET","HEAD"): ops.append("取得")
+ if method in("POST","PUT","PATCH","DELETE"):
+ if CREATE.search(seg): ops.append("作成")
+ if UPDATE.search(seg): ops.append("更新")
+ if LOGIC.search(seg): ops.append("論理削除")
+ if PHYS.search(seg): ops.append("物理削除")
+ # DELETEメソッドで何も拾えない場合
+ if method=="DELETE" and not any(x in ops for x in("論理削除","物理削除")):
+ ops.append("削除(方式不明)")
+ return ";".join(dict.fromkeys(ops)) if ops else ("取得" if method in("GET","HEAD") else "-")
+
+nc=0
+for c in data:
+ method=(c[4] or "GET").split(",")[0]
+ fp=c[13] if (len(c)>13) else ""; ln=c[14] if len(c)>14 else "0"
+ # ModelView/frameworkは実パス無し→methodベース
+ if "ModelView" in c[2] or c[2]=="フレームワーク" or not str(ln).isdigit() or ln=="0":
+ act=c[11].split(".")[-1] if len(c)>11 else ""
+ if act=="delete_view": v="物理削除(Flask-Admin ModelView.delete_model=db.session.delete)"
+ elif act=="create_view": v="作成"
+ elif act=="edit_view": v="更新"
+ elif act in("index_view","details_view","export","ajax_lookup"): v="取得"
+ elif method in("GET","HEAD"): v="取得"
+ else: v="-"
+ else:
+ seg=func_src(fp,ln)
+ v=eval4(c,seg,method)
+ c.append(v)
+ if "論理削除" in v or "物理削除" in v: nc+=1
+open(R+"weko3_api_list.tsv","w",encoding="utf-8").write("\t".join(hd+["data_op_detail"])+"\n"+
+ "\n".join("\t".join(str(x).replace("\t"," ") for x in c) for c in data)+"\n")
+print("data_op_detail付与。削除系(論理/物理):",nc,"列数:",len(hd)+1)
diff --git a/tools/api-inventory/scripts/add_idempotency.py b/tools/api-inventory/scripts/add_idempotency.py
new file mode 100644
index 0000000000..d26567429d
--- /dev/null
+++ b/tools/api-inventory/scripts/add_idempotency.py
@@ -0,0 +1,39 @@
+import ast,re,os
+R="/home/mhaya/wekov2/"
+def load(p): return [l.rstrip("\n").split("\t") for l in open(p,encoding="utf-8") if l.rstrip("\n")]
+rows=load(R+"weko3_api_list.tsv"); hd=rows[0]; data=rows[1:]
+srccache={}
+def get_src(fp,ln):
+ key=(fp,ln)
+ if key in srccache: return srccache[key]
+ full=os.path.join(R,fp)
+ if not os.path.isfile(full) or not str(ln).isdigit() or ln=="0": srccache[key]=""; return ""
+ try:
+ lines=open(full,encoding="utf-8",errors="replace").read().splitlines(); tree=ast.parse("\n".join(lines))
+ except: srccache[key]=""; return ""
+ best=None
+ for n in ast.walk(tree):
+ if isinstance(n,(ast.FunctionDef,ast.AsyncFunctionDef)):
+ s=n.lineno; e=getattr(n,"end_lineno",n.lineno)
+ if s<=int(ln)<=e and (best is None or (e-s)<(best[1]-best[0])): best=(s,e)
+ seg="\n".join(lines[best[0]-1:best[1]]) if best else ""; srccache[key]=seg; return seg
+def col_idem(c,seg):
+ m=c[4].split(",")[0]
+ if m in("GET","HEAD"): return "N/A(参照系)"
+ if m in("PUT","DELETE"):
+ # 状態チェックあれば冪等
+ if re.search(r"status\s*!=|status\s*==|already|if.*exists|get_or_404|ActionStatus",seg): return "状態チェックあり(概ね冪等)"
+ return "冪等性未確認(状態遷移PUT/DELETE)"
+ if m=="POST":
+ if re.search(r"action_status|ActionStatusPolicy|activity.*status|check_authority",seg): return "★状態遷移POST:二重送信で多重遷移の懸念(冪等性なし)"
+ if re.search(r"\.create\(|insert|add\(",seg): return "作成POST:二重送信で重複作成の懸念"
+ return "-"
+ return "-"
+nc=0
+for c in data:
+ seg=get_src(c[13],c[14]) if (len(c)>14) else ""
+ v=col_idem(c,seg); c.append(v)
+ if "★" in v: nc+=1
+open(R+"weko3_api_list.tsv","w",encoding="utf-8").write("\t".join(hd+["idempotency"])+"\n"+
+ "\n".join("\t".join(str(x).replace("\t"," ") for x in c) for c in data)+"\n")
+print("idempotency ★状態遷移:",nc,"列数:",len(hd)+1)
diff --git a/tools/api-inventory/scripts/add_ssrf_redirect.py b/tools/api-inventory/scripts/add_ssrf_redirect.py
new file mode 100644
index 0000000000..b8363213ca
--- /dev/null
+++ b/tools/api-inventory/scripts/add_ssrf_redirect.py
@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+"""redirect_target(オープンリダイレクト面) と ssrf_surface(SSRF面) を実装から機械付与"""
+import ast,re,os
+R="/home/mhaya/wekov2/"
+def load(p): return [l.rstrip("\n").split("\t") for l in open(p,encoding="utf-8") if l.rstrip("\n")]
+rows=load(R+"weko3_api_list.tsv"); hd=rows[0]; data=rows[1:]
+
+srccache={}
+def get_src(fp,ln):
+ key=(fp,ln)
+ if key in srccache: return srccache[key]
+ full=os.path.join(R,fp)
+ if not os.path.isfile(full) or not str(ln).isdigit(): srccache[key]=""; return ""
+ try:
+ lines=open(full,encoding="utf-8",errors="replace").read().splitlines()
+ tree=ast.parse("\n".join(lines))
+ except Exception: srccache[key]=""; return ""
+ best=None
+ for n in ast.walk(tree):
+ if isinstance(n,(ast.FunctionDef,ast.AsyncFunctionDef)):
+ s=n.lineno; e=getattr(n,"end_lineno",n.lineno)
+ if s<=int(ln)<=e and (best is None or (e-s)<(best[1]-best[0])): best=(s,e)
+ seg="\n".join(lines[best[0]-1:best[1]]) if best else ""
+ srccache[key]=seg; return seg
+
+REDIR_UNSAFE=re.compile(r"redirect\([^)]*(request\.(args|values|form|referrer|full_path)|session\[['\"]next)")
+REDIR_ANY=re.compile(r"\bredirect\(")
+URLVALIDATE=re.compile(r"is_safe_url|url_has_allowed_host|validate_redirect|urlparse.*netloc")
+SSRF=re.compile(r"requests\.(get|post|put|delete|head)\(|urlopen\(|urllib.*urlopen")
+SSRF_USERURL=re.compile(r"requests\.\w+\(\s*[^)]*(request\.|_url|list_url|base_url|\+ *index|\+ *tmpindex|format\()")
+
+def col_redirect(seg):
+ if not REDIR_ANY.search(seg): return "-"
+ if REDIR_UNSAFE.search(seg):
+ safe="(host検証あり)" if URLVALIDATE.search(seg) else "★検証なし"
+ return f"外部入力でリダイレクト先決定{safe}"
+ return "内部リダイレクト(固定/url_for)"
+
+def col_ssrf(seg):
+ if not SSRF.search(seg): return "-"
+ if SSRF_USERURL.search(seg):
+ return "★外部/設定URLへHTTP発行(SSRF面)"
+ return "外部HTTP(固定URL)"
+
+newcols=["redirect_target","ssrf_surface"]
+nr=ns=0
+for c in data:
+ seg=get_src(c[13],c[14]) if (len(c)>14 and str(c[14]).isdigit() and c[14]!="0") else ""
+ r=col_redirect(seg); s=col_ssrf(seg)
+ if "★" in r: nr+=1
+ if "★" in s: ns+=1
+ c += [r,s]
+open(R+"weko3_api_list.tsv","w",encoding="utf-8").write("\t".join(hd+newcols)+"\n"+
+ "\n".join("\t".join(str(x).replace("\t"," ") for x in c) for c in data)+"\n")
+print(f"redirect_target ★検証なし:{nr} ssrf_surface ★:{ns} 列数:{len(hd)+2}")
diff --git a/tools/api-inventory/scripts/apply2.py b/tools/api-inventory/scripts/apply2.py
new file mode 100644
index 0000000000..a9ce0c5a91
--- /dev/null
+++ b/tools/api-inventory/scripts/apply2.py
@@ -0,0 +1,58 @@
+# [参考実装] 動的検証用。セッション固有の絶対パス(scratchpad/ck等)を含むため、
+# 再利用時は BASE/HOST/Cookie保存先/probe対象TSVパスを環境に合わせて修正すること。
+# 手順は tools/api-inventory/README.md の Phase 3 を参照。
+import json,re,sys,collections
+ROOTF="/home/mhaya/wekov2/"
+P="/tmp/claude-1000/-home-mhaya-wekov2/a8119b60-023e-4882-84ac-a0edcfb5627e/scratchpad/api"
+def load(p): return [l.rstrip("\n").split("\t") for l in open(p,encoding="utf-8") if l.rstrip("\n")]
+rows=load(ROOTF+"weko3_api_list.tsv"); hd=rows[0]; data=rows[1:]
+res=json.load(open(P+"/probe_results.json"))
+# 500判定(method+urlキー)
+byp=set()
+for l in open(P+"/status500_verdict.tsv",encoding="utf-8"):
+ a=l.rstrip("\n").split("\t")
+ if len(a)>=3 and "到達" in a[2]: byp.add((a[0],a[1]))
+def resolve(uri):
+ u=uri
+ for pat,val in [(r"","v1"),(r"","x:y:secret.png"),(r"<[^>]*api_code>","crf"),
+ (r"<[^>]*index_id>","100100"),(r"<[^>]*journal_id>","1"),(r"","top_page_access"),
+ (r"","2026"),(r"","8"),(r"<[^>]*(file_name|filename|key)>","secret.png"),
+ (r"]+>","secret.png"),(r"<[^>]*(pid_value|recid|identifier)>","1001"),
+ (r"<[^>]*activity_id>","A-1"),(r"<[^>]*community_id>","comm1"),(r"<[^>]*(id|Id)>","1"),(r"<[^>]+>","1")]:
+ u=re.sub(pat,val,u)
+ return u
+
+def kind(status, method=None, url=None):
+ if status in ("302","401","403"): return "遮断"
+ if status=="500":
+ return "到達" if (method,url) in byp else "遮断"
+ if status in ("404","405"): return "検証不能"
+ if status in ("000","ERR"): return "無応答"
+ if status and status[0] in "245": return "到達" # 2xx/400/415/308
+ return "?"
+
+DVI=45
+summary=collections.Counter()
+for i,c in enumerate(data):
+ if str(i) not in res: continue
+ r=res[str(i)]; method=(c[4] or "GET").split(",")[0]; url=resolve(c[5])
+ parts=[]; kinds={}
+ for ident in ("anon","general","comadmin","repoadmin"):
+ if ident in r:
+ k=kind(r[ident],method,url); kinds[ident]=k
+ parts.append(f"{ident}={r[ident]}({k})")
+ # 判定: 最小権限で到達したものを重大度順に
+ if kinds.get("anon")=="到達": v="未認証で到達"
+ elif kinds.get("general")=="到達": v="ログインのみで到達"
+ elif kinds.get("comadmin")=="到達": v="Community管理者で到達"
+ elif kinds.get("repoadmin")=="到達": v="Repository管理者で到達"
+ elif "検証不能" in kinds.values() and "到達" not in kinds.values(): v="検証不能(テストURL未解決)"
+ else: v="測定範囲では遮断"
+ summary[v]+=1
+ while len(c)<=DVI: c.append("-")
+ c[DVI]=f"[実測] {v} | "+"; ".join(parts)
+with open(ROOTF+"weko3_api_list.tsv","w",encoding="utf-8") as f:
+ f.write("\t".join(hd)+"\n")
+ for c in data: f.write("\t".join(x.replace("\t"," ") for x in c)+"\n")
+print("=== 実測判定サマリ(223フラグ付き) ===")
+for k,n in summary.most_common(): print(f" {n:4d} {k}")
diff --git a/tools/api-inventory/scripts/apply_probe.py b/tools/api-inventory/scripts/apply_probe.py
new file mode 100644
index 0000000000..5448383a5b
--- /dev/null
+++ b/tools/api-inventory/scripts/apply_probe.py
@@ -0,0 +1,61 @@
+# [参考実装] 動的検証用。セッション固有の絶対パス(scratchpad/ck等)を含むため、
+# 再利用時は BASE/HOST/Cookie保存先/probe対象TSVパスを環境に合わせて修正すること。
+# 手順は tools/api-inventory/README.md の Phase 3 を参照。
+# -*- coding: utf-8 -*-
+import json,re
+ROOTF="/home/mhaya/wekov2/"
+def load(p): return [l.rstrip("\n").split("\t") for l in open(p,encoding="utf-8") if l.rstrip("\n")]
+rows=load(ROOTF+"weko3_api_list.tsv"); hd=rows[0]; data=rows[1:]
+res=json.load(open(ROOTF.replace("/home/mhaya/wekov2/","")+"/tmp/claude-1000/-home-mhaya-wekov2/a8119b60-023e-4882-84ac-a0edcfb5627e/scratchpad/api/probe_results.json"))
+
+def cls(st):
+ if st in ("302","401","403"): return "遮断"
+ if st in ("404","405"): return "経路なし/不許可"
+ if st=="000" or st=="ERR": return "無応答"
+ if st.startswith("2"): return "到達/成功"
+ if st.startswith("4"): return "到達(handler4xx)"
+ if st.startswith("5"): return "到達(handler5xx)"
+ return st
+
+# 既存の統合列(42-46)はそのまま残し、動的実測を上書き強化する47列目相当は作らず
+# 46 dynamic_verified を実測ベースで全面的に書き換える
+DVI=45 # 0-based index of dynamic_verified (46th col)
+for i,c in enumerate(data):
+ key=str(i)
+ if key not in res:
+ continue
+ r=res[key]
+ parts=[]
+ for ident in ("anon","general","comadmin","repoadmin"):
+ if ident in r:
+ parts.append(f"{ident}={r[ident]}({cls(r[ident])})")
+ measured="; ".join(parts)
+ # 判定サマリ
+ anon=r.get("anon",""); gen=r.get("general","")
+ def reached(s): return s.startswith("2") or s.startswith("4") and s not in("401","403","404","405") or s.startswith("5")
+ verdict=""
+ if reached(anon): verdict="未認証で到達"
+ elif gen and reached(gen): verdict="ログインのみで到達"
+ elif r.get("comadmin","") and reached(r["comadmin"]): verdict="Community管理者で到達"
+ elif r.get("repoadmin","") and reached(r["repoadmin"]): verdict="Repository管理者で到達"
+ else: verdict="全ロールで遮断/未到達"
+ # 既存のdynamic_verified(実機で個別確認済みの詳細)があれば前置
+ prev=c[DVI] if len(c)>DVI and c[DVI]!="-" else ""
+ combined=(prev+" || " if prev else "")+f"[実測] {verdict} : {measured}"
+ while len(c)<=DVI: c.append("-")
+ c[DVI]=combined
+
+with open(ROOTF+"weko3_api_list.tsv","w",encoding="utf-8") as f:
+ f.write("\t".join(hd)+"\n")
+ for c in data: f.write("\t".join(x.replace("\t"," ") for x in c)+"\n")
+
+# サマリ
+import collections
+cnt=collections.Counter()
+for i,c in enumerate(data):
+ if str(i) in res:
+ v=c[DVI]
+ for k in ("未認証で到達","ログインのみで到達","Community管理者で到達","Repository管理者で到達","全ロールで遮断"):
+ if k in v: cnt[k]+=1; break
+for k,n in cnt.most_common(): print(f"{n:4d} {k}")
+print("total measured:",sum(cnt.values()))
diff --git a/tools/api-inventory/scripts/asuser.sh b/tools/api-inventory/scripts/asuser.sh
new file mode 100755
index 0000000000..f8e1935036
--- /dev/null
+++ b/tools/api-inventory/scripts/asuser.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+# asuser.sh [curl-extra...]
+S=/tmp/claude-1000/-home-mhaya-wekov2/a8119b60-023e-4882-84ac-a0edcfb5627e/scratchpad/api
+H='Host: weko3.example.org'; B=https://localhost:8443
+jar=$(mktemp)
+curl -sk -c "$jar" -o /dev/null --max-time 10 -H "$H" -X POST "$B/api/v1/login" -H 'Content-Type: application/json' -d "{\"email\":\"$1\",\"password\":\"Passw0rd!123\"}"
+m=$2; p=$3; shift 3
+curl -sk -b "$jar" -o /dev/null -w '%{http_code}' --max-time 12 -H "$H" -X "$m" "$@" "$B$p"
+rm -f "$jar"
diff --git a/tools/api-inventory/scripts/audit_decorators.py b/tools/api-inventory/scripts/audit_decorators.py
new file mode 100644
index 0000000000..d21c0f8fb9
--- /dev/null
+++ b/tools/api-inventory/scripts/audit_decorators.py
@@ -0,0 +1,114 @@
+# -*- coding: utf-8 -*-
+"""認証デコレータの機械的な不整合検出 (AST)."""
+import ast, os, json, sys, collections
+
+ROOT='/home/mhaya/wekov2'
+SKIP=('/tests','/examples','/.tox','/node_modules','/cookiecutter')
+
+AUTH = {'login_required','login_required_customize','roles_required','require_api_auth',
+ 'require_oauth_scopes','need_record_permission','need_permissions','check_authority',
+ 'stats_api_access_required','check_index_access_permissions','check_on_behalf_of',
+ 'require_oauth','pass_record'}
+PERM_SUFFIX = ('_permission.require','permission.require')
+
+def dname(d):
+ c=d.func if isinstance(d,ast.Call) else d
+ p=[]
+ while isinstance(c,ast.Attribute):
+ p.append(c.attr); c=c.value
+ if isinstance(c,ast.Name): p.append(c.id)
+ return '.'.join(reversed(p))
+
+def is_auth(n):
+ return n.split('.')[-1] in AUTH or n.endswith('permission.require') or 'require' in n.split('.')[-1]
+
+findings=collections.defaultdict(list)
+handlers=[] # (file, line, kind, name, decorators, cls)
+
+for dp,dn,fn in os.walk(os.path.join(ROOT,'modules')):
+ if any(s in dp+'/' for s in SKIP): continue
+ for f in sorted(fn):
+ if not f.endswith('.py'): continue
+ fp=os.path.join(dp,f); rel=os.path.relpath(fp,ROOT)
+ try:
+ text=open(fp,encoding='utf-8',errors='replace').read(); tree=ast.parse(text)
+ except Exception: continue
+ lines=text.splitlines()
+
+ # --- 1. コメントアウトされた認証デコレータ ---
+ for i,l in enumerate(lines,1):
+ s=l.strip()
+ if s.startswith('#') and '@' in s:
+ body=s.lstrip('#').strip()
+ if body.startswith('@'):
+ nm=body[1:].split('(')[0]
+ if is_auth(nm) or 'permission' in nm:
+ findings['commented_auth'].append((rel,i,body[:90]))
+
+ # --- ハンドラ収集 ---
+ for node in ast.walk(tree):
+ if isinstance(node,ast.ClassDef):
+ for b in node.body:
+ if isinstance(b,(ast.FunctionDef,ast.AsyncFunctionDef)):
+ decs=[dname(d) for d in b.decorator_list]
+ if b.name in ('get','post','put','delete','patch','head') or any(d.endswith('.route') or d=='expose' for d in decs):
+ handlers.append((rel,b.lineno,'method',b.name,decs,node.name))
+ if isinstance(node,(ast.FunctionDef,ast.AsyncFunctionDef)):
+ decs=[dname(d) for d in node.decorator_list]
+ if any(d.endswith('.route') for d in decs):
+ handlers.append((rel,node.lineno,'route',node.name,decs,None))
+ if any(d=='expose' or d.endswith('.expose') for d in decs):
+ handlers.append((rel,node.lineno,'expose',node.name,decs,None))
+
+# --- 2. require_oauth_scopes が require_api_auth 無しで使われている ---
+for rel,ln,kind,name,decs,cls in handlers:
+ short=[d.split('.')[-1] for d in decs]
+ if 'require_oauth_scopes' in short and not ('require_api_auth' in short or 'require_oauth' in short):
+ findings['scope_without_auth'].append((rel,ln,f"{cls+'.' if cls else ''}{name}",decs))
+
+# --- 3. roles_required が実質無効 (引数空 / allow_anonymous=True) ---
+for dp,dn,fn in os.walk(os.path.join(ROOT,'modules')):
+ if any(s in dp+'/' for s in SKIP): continue
+ for f in fn:
+ if not f.endswith('.py'): continue
+ fp=os.path.join(dp,f); rel=os.path.relpath(fp,ROOT)
+ try: tree=ast.parse(open(fp,encoding='utf-8',errors='replace').read())
+ except Exception: continue
+ for node in ast.walk(tree):
+ if isinstance(node,(ast.FunctionDef,ast.AsyncFunctionDef)):
+ for d in node.decorator_list:
+ if not isinstance(d,ast.Call): continue
+ if dname(d).split('.')[-1]!='roles_required': continue
+ empty = bool(d.args) and isinstance(d.args[0],ast.List) and len(d.args[0].elts)==0
+ anon = any(k.arg=='allow_anonymous' and getattr(k.value,'value',None) is True for k in d.keywords)
+ if empty or anon:
+ findings['roles_required_noop'].append(
+ (rel,node.lineno,node.name,f"empty_list={empty} allow_anonymous={anon}"))
+
+# --- 4. 同一クラス内でデコレータが不揃いなハンドラ ---
+byclass=collections.defaultdict(list)
+for rel,ln,kind,name,decs,cls in handlers:
+ if cls: byclass[(rel,cls)].append((ln,name,decs))
+for (rel,cls),ms in byclass.items():
+ if len(ms)<2: continue
+ authed=[m for m in ms if any(is_auth(d.split('.')[-1]) or 'permission' in d for d in m[2])]
+ bare =[m for m in ms if not any(is_auth(d.split('.')[-1]) or 'permission' in d for d in m[2])]
+ if authed and bare:
+ findings['inconsistent_in_class'].append(
+ (rel,cls,[f"{m[1]}@{m[0]}" for m in bare],[f"{m[1]}@{m[0]}" for m in authed]))
+
+# --- 5. 同一 admin.py 内で @expose に権限デコレータの有無が混在 ---
+bymod=collections.defaultdict(list)
+for rel,ln,kind,name,decs,cls in handlers:
+ if kind=='expose': bymod[rel].append((ln,name,decs))
+for rel,ms in bymod.items():
+ if len(ms)<2: continue
+ permed=[m for m in ms if any('permission' in d for d in m[2])]
+ bare =[m for m in ms if not any('permission' in d for d in m[2])]
+ if permed and bare:
+ findings['inconsistent_expose'].append(
+ (rel,len(permed),[f"{m[1]}@{m[0]}" for m in bare]))
+
+json.dump({k:v for k,v in findings.items()}, open(sys.argv[1],'w',encoding='utf-8'),
+ ensure_ascii=False, indent=1, default=str)
+for k,v in findings.items(): print(f"{k}: {len(v)}")
diff --git a/tools/api-inventory/scripts/audit_injection.py b/tools/api-inventory/scripts/audit_injection.py
new file mode 100644
index 0000000000..7f78061fd7
--- /dev/null
+++ b/tools/api-inventory/scripts/audit_injection.py
@@ -0,0 +1,78 @@
+# -*- coding: utf-8 -*-
+"""注入・パストラバーサル系の機械検出 (AST + 正規表現)."""
+import ast, os, re, json, sys, collections
+ROOT='/home/mhaya/wekov2'
+SKIP=('/tests','/examples','/.tox','/node_modules','/cookiecutter','/docs/')
+F=collections.defaultdict(list)
+
+# ユーザ入力を表す式
+USER_SRC = re.compile(r"request\.(args|form|values|json|view_args|data|files|headers|cookies)"
+ r"|get_json\(|request\.stream|kwargs\.get\(")
+
+def dname(d):
+ c=d.func if isinstance(d,ast.Call) else d
+ p=[]
+ while isinstance(c,ast.Attribute): p.append(c.attr); c=c.value
+ if isinstance(c,ast.Name): p.append(c.id)
+ return '.'.join(reversed(p))
+
+for dp,dn,fn in os.walk(os.path.join(ROOT,'modules')):
+ if any(s in dp+'/' for s in SKIP): continue
+ for f in sorted(fn):
+ if not f.endswith('.py'): continue
+ fp=os.path.join(dp,f); rel=os.path.relpath(fp,ROOT)
+ try:
+ text=open(fp,encoding='utf-8',errors='replace').read(); tree=ast.parse(text)
+ except Exception: continue
+ lines=text.splitlines()
+
+ for node in ast.walk(tree):
+ # 1. eval / exec
+ if isinstance(node,ast.Call) and dname(node.func) in ('eval','exec'):
+ seg='\n'.join(lines[max(0,node.lineno-3):node.lineno+1])
+ F['eval_exec'].append((rel,node.lineno,lines[node.lineno-1].strip()[:100],
+ 'USER_INPUT' if USER_SRC.search(seg) else ''))
+ # 2. pickle.loads / yaml.load
+ if isinstance(node,ast.Call) and dname(node.func) in ('pickle.loads','yaml.load','marshal.loads'):
+ F['deserialize'].append((rel,node.lineno,lines[node.lineno-1].strip()[:100]))
+ # 3. subprocess with shell=True
+ if isinstance(node,ast.Call) and dname(node.func).startswith(('subprocess.','os.system','os.popen')):
+ sh=any(k.arg=='shell' and getattr(k.value,'value',None) is True for k in node.keywords)
+ if sh or dname(node.func) in ('os.system','os.popen'):
+ F['shell'].append((rel,node.lineno,lines[node.lineno-1].strip()[:100]))
+
+ # 4. ファイル保存/パス組み立てに secure_filename を使っていない
+ for i,l in enumerate(lines,1):
+ if re.search(r"\.save\(|PyFSFileStorage\(|open\(\s*[a-z_]*(path|url|dir|file)", l, re.I):
+ ctx='\n'.join(lines[max(0,i-12):i+2])
+ if USER_SRC.search(ctx) and 'secure_filename' not in ctx:
+ F['path_unsafe'].append((rel,i,l.strip()[:110]))
+ # 5. ES painless / クエリへの文字列連結
+ for i,l in enumerate(lines,1):
+ if re.search(r"(source|script|inline)\s*[:=].*(\+|format\(|f['\"])", l) and 'painless' in '\n'.join(lines[max(0,i-6):i+6]):
+ F['es_script_concat'].append((rel,i,l.strip()[:110]))
+ # 6. 生SQLへの文字列連結
+ for i,l in enumerate(lines,1):
+ if re.search(r"(execute|text)\s*\(\s*(f['\"]|['\"].*['\"]\s*\+|['\"].*%s)", l):
+ F['sql_concat'].append((rel,i,l.strip()[:110]))
+ # 7. except で return が無い (None返却 → 500)
+ for node in ast.walk(tree):
+ if isinstance(node,ast.FunctionDef):
+ has_ret=any(isinstance(x,ast.Return) and x.value is not None for x in ast.walk(node))
+ if not has_ret: continue
+ for h in [x for x in ast.walk(node) if isinstance(x,ast.ExceptHandler)]:
+ if not any(isinstance(s,(ast.Return,ast.Raise)) for s in ast.walk(h)):
+ # 最後の except で return が無く、関数末尾にも return が無い
+ last=node.body[-1]
+ if not isinstance(last,(ast.Return,ast.Raise)):
+ F['except_no_return'].append((rel,h.lineno,node.name))
+ break
+ # 8. ミュータブルなデフォルト引数
+ for node in ast.walk(tree):
+ if isinstance(node,(ast.FunctionDef,ast.AsyncFunctionDef)):
+ for d in node.args.defaults+node.args.kw_defaults:
+ if isinstance(d,(ast.List,ast.Dict,ast.Set)):
+ F['mutable_default'].append((rel,node.lineno,node.name)); break
+
+json.dump(F,open(sys.argv[1],'w',encoding='utf-8'),ensure_ascii=False,indent=1,default=str)
+for k,v in sorted(F.items(),key=lambda x:-len(x[1])): print(f"{k}: {len(v)}")
diff --git a/tools/api-inventory/scripts/build_checklist.py b/tools/api-inventory/scripts/build_checklist.py
new file mode 100644
index 0000000000..42590a298c
--- /dev/null
+++ b/tools/api-inventory/scripts/build_checklist.py
@@ -0,0 +1,66 @@
+# -*- coding: utf-8 -*-
+"""57列詳細版 → 24列チェックリスト版に統合"""
+import os, sys
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from paths import data_path
+SRC = sys.argv[1] if len(sys.argv) > 1 else data_path("weko3_api_list_full.tsv")
+DST = sys.argv[2] if len(sys.argv) > 2 else data_path("weko3_api_list.tsv")
+def load(p): return [l.rstrip("\n").split("\t") for l in open(p,encoding="utf-8") if l.rstrip("\n")]
+rows=load(SRC); hd=rows[0]; data=rows[1:]
+H={name:i for i,name in enumerate(hd)}
+def g(c,name):
+ i=H[name]; return c[i] if len(c)>i and c[i] not in("","-","不明") else ""
+
+# 24列チェックリスト設計
+NEW=["no","module","api_type","method","uri","impl","summary",
+ "auth","roles_scope","access_variance","data_op","data_store","side_effects",
+ "security_finding","security_flags","dynamic_verified",
+ "api_version","deprecated","test_file","last_change","tags","notes","config_deps","response",
+ # 末尾に追加する。既存列の位置を動かすと README の awk 例が全て壊れるため。
+ "priority","priority_reason",
+ "test_normal","test_abnormal","test_boundary","test_exception","test_gap","cleanup"]
+
+out=[NEW]
+for c in data:
+ def j(parts,sep=" | "): return sep.join(p for p in parts if p)
+ impl = j([g(c,"impl_func"), (g(c,"impl_file")+(":"+g(c,"impl_line") if g(c,"impl_line") and g(c,"impl_line")!="0" else ""))], " @")
+ # auth: 要否+方式+仕組み
+ auth = j([g(c,"auth_required"), g(c,"auth_method"), "["+g(c,"auth_mechanism").split("(")[0]+"]" if g(c,"auth_mechanism") else ""])
+ roles_scope = j([g(c,"roles"), ("scope:"+g(c,"oauth_scope")) if g(c,"oauth_scope") else ""])
+ access_var = j([g(c,"auth_response_variance"), g(c,"restricted_content")], " / ")
+ data_op = g(c,"data_op_detail") or g(c,"data_op")
+ data_store = j([g(c,"data_target"), g(c,"data_store")], " → ")
+ side = j([g(c,"side_effects"), ("task:"+g(c,"triggers_task")) if g(c,"triggers_task") else ""])
+ # security_finding: sec_pattern中心にexposed/detail/evidenceを要約
+ sf_parts=[g(c,"sec_pattern")]
+ if g(c,"sec_exposed"): sf_parts.append("露出:"+g(c,"sec_exposed"))
+ if g(c,"sec_evidence"): sf_parts.append(g(c,"sec_evidence"))
+ security_finding=j(sf_parts, " ; ")
+ # security_flags: 7観点を該当のみ集約
+ flags=[]
+ for col,label in [("csrf_protection","CSRF"),("input_validation","INPUT"),("audit_logged","AUDIT"),
+ ("resource_limit","RESLIMIT"),("redirect_target","REDIRECT"),("ssrf_surface","SSRF"),
+ ("idempotency","IDEMP"),("bola_risk","BOLA")]:
+ v=g(c,col)
+ if v and ("★" in v or "なし" in v or "slip" in v.lower() or "生入力" in v or "外部" in v or "冪等性なし" in v or "多重遷移" in v or "全走査" in v or "検証なし" in v):
+ flags.append(f"{label}:{v[:40]}")
+ security_flags=j(flags, " ; ")
+ last_change=j([g(c,"last_commit"), g(c,"last_commit_date"), g(c,"release_tag")], " ")
+ notes=j([g(c,"notes"), ("例外:"+g(c,"exceptions")) if g(c,"exceptions") else ""], " || ")
+ resp=j([g(c,"response"), g(c,"status_codes")], " / ")
+
+ out.append([
+ g(c,"no"),g(c,"module"),g(c,"api_type"),g(c,"method"),g(c,"uri"),impl or "-",g(c,"summary") or "-",
+ auth or "-", roles_scope or "-", access_var or "-", data_op or "-", data_store or "-", side or "-",
+ security_finding or "-", security_flags or "-", g(c,"dynamic_verified") or "-",
+ g(c,"api_version") or "-", g(c,"deprecated") or "-", g(c,"test_file") or "-", last_change or "-",
+ g(c,"category_tags") or "-", notes or "-", g(c,"config_deps") or "-", resp or "-",
+ g(c,"priority") or "-", g(c,"priority_reason") or "-",
+ g(c,"test_normal") or "-", g(c,"test_abnormal") or "-",
+ g(c,"test_boundary") or "-", g(c,"test_exception") or "-",
+ g(c,"test_gap") or "-", g(c,"cleanup") or "-"
+ ])
+with open(DST,"w",encoding="utf-8") as f:
+ for r in out: f.write("\t".join(str(x).replace("\t"," ").replace("\n"," ") for x in r)+"\n")
+print("チェックリスト版:",len(out)-1,"行 ×",len(NEW),"列")
+print("列:",NEW)
diff --git a/tools/api-inventory/scripts/changed_rows.py b/tools/api-inventory/scripts/changed_rows.py
new file mode 100644
index 0000000000..627517d329
--- /dev/null
+++ b/tools/api-inventory/scripts/changed_rows.py
@@ -0,0 +1,178 @@
+# -*- coding: utf-8 -*-
+"""git 差分から「再レビューが必要なインベントリ行」を機械的に絞り込む。
+
+ python3 changed_rows.py v2.0.3 HEAD --tsv ../weko3_api_list_full.tsv
+
+`git diff -U0 ..` の変更行を、その行を含む def/class の範囲に広げ、
+インベントリの impl_file(14列) / impl_line(15列) と突き合わせる。
+918行すべてを再測定せず、変更が実際に触れた行だけを Phase2-3 に回すためのもの。
+
+`enrich_git.py` の `enclosing()` と同じ考え方(関数単位)。
+"""
+import argparse
+import ast
+import collections
+import functools
+import os
+import re
+import subprocess
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from paths import data_path # noqa: E402
+import warnings
+
+warnings.filterwarnings('ignore', category=SyntaxWarning)
+
+HUNK = re.compile(r'^@@ -\S+ \+(\d+)(?:,(\d+))? @@')
+
+
+def default_weko_root():
+ """解析対象リポジトリのルートを決める。
+
+ 1. 環境変数 WEKO_ROOT
+ 2. このスクリプトを含む git リポジトリのトップ(`modules/` があれば採用)
+ → WEKO3 リポジトリ内(例: tools/api-inventory/scripts/)に配置した場合に効く
+ 3. 従来の既定(weko-document から wekov2 を解析する場合)
+ """
+ env = os.environ.get('WEKO_ROOT')
+ if env:
+ return env
+ here = os.path.dirname(os.path.abspath(__file__))
+ try:
+ top = subprocess.run(['git', '-C', here, 'rev-parse', '--show-toplevel'],
+ capture_output=True, text=True).stdout.strip()
+ except Exception:
+ top = ''
+ if top and os.path.isdir(os.path.join(top, 'modules')):
+ return top
+ return '/home/mhaya/wekov2'
+
+
+def sh(args):
+ return subprocess.run(args, capture_output=True, text=True).stdout
+
+
+@functools.lru_cache(maxsize=None)
+def def_ranges(root, rel):
+ fp = os.path.join(root, rel)
+ if not os.path.isfile(fp):
+ return ()
+ try:
+ tree = ast.parse(open(fp, encoding='utf-8', errors='replace').read())
+ except Exception:
+ return ()
+ out = []
+ for n in ast.walk(tree):
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
+ start = min([n.lineno] + [d.lineno for d in n.decorator_list])
+ out.append((start, getattr(n, 'end_lineno', n.lineno)))
+ return tuple(out)
+
+
+def enclosing(root, rel, line):
+ """line を含む最小の def/class 範囲。無ければ (line, line)。"""
+ best = None
+ for s, e in def_ranges(root, rel):
+ if s <= line <= e and (best is None or (e - s) < (best[1] - best[0])):
+ best = (s, e)
+ return best or (line, line)
+
+
+def changed_line_ranges(root, base, head):
+ """{rel_path: [(start, end), ...]} — 変更後(+側)の行範囲。"""
+ diff = sh(['git', '-C', root, 'diff', '-U0', f'{base}..{head}', '--', 'modules/'])
+ out = collections.defaultdict(list)
+ rel = None
+ for line in diff.splitlines():
+ if line.startswith('+++ b/'):
+ rel = line[6:].strip()
+ elif line.startswith('+++ /dev/null'):
+ rel = None
+ elif line.startswith('@@') and rel:
+ m = HUNK.match(line)
+ if m:
+ start = int(m.group(1))
+ count = int(m.group(2) or 1)
+ if count:
+ out[rel].append((start, start + count - 1))
+ return out
+
+
+def main():
+ p = argparse.ArgumentParser(
+ description='git 差分 → 再レビューが必要なインベントリ行')
+ p.add_argument('base', help='比較元(前回チェック時のタグ/コミット)')
+ p.add_argument('head', nargs='?', default='HEAD')
+ p.add_argument('--weko-root', default=default_weko_root())
+ p.add_argument('--tsv', default=None,
+ help='既定: $WEKO_API_INVENTORY_DIR/weko3_api_list_full.tsv')
+ p.add_argument('--out', help='該当 no の一覧(1行1件)の出力先')
+ a = p.parse_args()
+ a.tsv = a.tsv or data_path('weko3_api_list_full.tsv')
+
+ root = os.path.abspath(a.weko_root)
+ ranges = changed_line_ranges(root, a.base, a.head)
+ if not ranges:
+ print(f'{a.base}..{a.head}: modules/ 配下に変更なし')
+ return
+
+ # 変更行 → その行を含む def/class 範囲へ拡張
+ touched = collections.defaultdict(set)
+ for rel, rs in ranges.items():
+ if not rel.endswith('.py'):
+ continue
+ for s, e in rs:
+ for line in range(s, e + 1):
+ touched[rel].add(enclosing(root, rel, line))
+
+ # インベントリ突き合わせ
+ hits, files_changed = [], set()
+ with open(a.tsv, encoding='utf-8') as f:
+ header = f.readline().rstrip('\n').split('\t')
+ i_no, i_uri = header.index('no'), header.index('uri')
+ i_file, i_line = header.index('impl_file'), header.index('impl_line')
+ i_method = header.index('method')
+ for raw in f:
+ c = raw.rstrip('\n').split('\t')
+ if len(c) <= i_line:
+ continue
+ rel, ln = c[i_file].strip(), c[i_line].strip()
+ if rel not in touched:
+ continue
+ files_changed.add(rel)
+ try:
+ ln = int(ln)
+ except ValueError:
+ continue
+ if any(s <= ln <= e for s, e in touched[rel]):
+ hits.append((c[i_no], c[i_method], c[i_uri], f'{rel}:{ln}'))
+
+ print(f'{a.base}..{a.head}')
+ print(f' 変更ファイル(modules/*.py): {len([r for r in ranges if r.endswith(".py")])}')
+ print(f' うちインベントリに載る実装ファイル: {len(files_changed)}')
+ print(f' 再レビュー対象行: {len(hits)} / 全{sum(1 for _ in open(a.tsv, encoding="utf-8")) - 1}行')
+ print()
+ for no, method, uri, impl in hits:
+ print(f' no={no:<5} {method:<10} {uri[:70]:<70} {impl}')
+
+ if a.out:
+ with open(a.out, 'w', encoding='utf-8') as f:
+ f.write('\n'.join(h[0] for h in hits) + '\n')
+ print(f'\n{a.out} に no 一覧を出力しました'
+ f'(probe.py の --only に渡せます)')
+
+ # 変更はあるがインベントリに載っていない実装ファイル = 新規APIの可能性
+ unmapped = sorted(set(r for r in ranges if r.endswith('.py')) - files_changed)
+ view_like = [r for r in unmapped
+ if os.path.basename(r) in ('views.py', 'rest.py', 'admin.py', 'ext.py', 'config.py')]
+ if view_like:
+ print()
+ print(' ⚠ インベントリに未登録だが views/rest/admin/ext/config が変更されたファイル:')
+ for r in view_like:
+ print(f' {r}')
+ print(' → 新規エンドポイントの可能性。snapshot.py の差分と併せて確認すること。')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/api-inventory/scripts/check_reachable.py b/tools/api-inventory/scripts/check_reachable.py
new file mode 100644
index 0000000000..dba69c316a
--- /dev/null
+++ b/tools/api-inventory/scripts/check_reachable.py
@@ -0,0 +1,41 @@
+# -*- coding: utf-8 -*-
+"""C/D 区分エンドポイントの登録チェーンを静的に確認する。"""
+import re, os, subprocess, sys
+ROOT='/home/mhaya/wekov2'
+
+# (表示名, blueprint変数が定義されたファイル, blueprint名, 期待する登録経路の検索パターン)
+TARGETS = [
+ ("weko_records_ui (UI)", "modules/weko-records-ui/weko_records_ui", "weko_records_ui",
+ [("setup.py","invenio_base.apps"),("ext.py","register_blueprint")]),
+ ("weko_deposit_rest (API)","modules/weko-deposit/weko_deposit", "weko_deposit_rest",
+ [("setup.py","invenio_base.api_apps"),("ext.py","register_blueprint")]),
+ ("weko_gridlayout (UI)", "modules/weko-gridlayout/weko_gridlayout", "weko_gridlayout",
+ [("setup.py","invenio_base.blueprints")]),
+ ("weko_gridlayout_api", "modules/weko-gridlayout/weko_gridlayout", "weko_gridlayout_api",
+ [("setup.py","invenio_base.api_blueprints")]),
+ ("weko_handle (UI)", "modules/weko-handle/weko_handle", "weko_handle",
+ [("setup.py","invenio_base.blueprints")]),
+ ("weko_items_ui_api", "modules/weko-items-ui/weko_items_ui", "weko_items_ui_api",
+ [("setup.py","invenio_base.api_blueprints")]),
+ ("weko_schema_rest (API)", "modules/weko-schema-ui/weko_schema_ui", "weko_schema_rest",
+ [("setup.py","invenio_base.api_apps"),("ext.py","register_blueprint")]),
+]
+
+def grep(path, pat):
+ if not os.path.isfile(path): return None
+ txt=open(path,encoding='utf-8',errors='replace').read()
+ for i,l in enumerate(txt.splitlines(),1):
+ if pat in l: return i,l.strip()[:100]
+ return None
+
+for name, pkg, bpname, checks in TARGETS:
+ mod=os.path.dirname(os.path.join(ROOT,pkg))
+ print(f"\n■ {name}")
+ ok=True
+ for fname, pat in checks:
+ p = os.path.join(mod,'setup.py') if fname=='setup.py' else os.path.join(ROOT,pkg,'ext.py')
+ r = grep(p, pat)
+ rel=os.path.relpath(p,ROOT)
+ if r: print(f" OK {rel}:{r[0]} {r[1]}")
+ else: print(f" NG {rel} ('{pat}' が見つからない)"); ok=False
+ print(f" => {'到達可能' if ok else '要確認'}")
diff --git a/tools/api-inventory/scripts/diff_snapshot.py b/tools/api-inventory/scripts/diff_snapshot.py
new file mode 100644
index 0000000000..77595fb77d
--- /dev/null
+++ b/tools/api-inventory/scripts/diff_snapshot.py
@@ -0,0 +1,364 @@
+# -*- coding: utf-8 -*-
+"""API スナップショット差分 — 追加/削除/仕様変更を機械的に検知する。
+
+ python3 diff_snapshot.py OLD.json NEW.json [--gate] [--out drift.md]
+
+分類:
+ ADDED / REMOVED 経路の増減
+ RULE_CHANGED endpoint 同一で URL が変化
+ METHODS_CHANGED HTTPメソッドの増減
+ AUTH_CHANGED 認証・認可デコレータの変化(最優先)
+ IMPL_CHANGED デコルータ据置きで実装本体が変化(認可ロジック内包の変化)
+ ATTRS_UNKNOWN_NEW 経路はあるが静的解析で属性が取れない新規
+
+ゲート(--gate 指定時、該当があれば exit 1):
+ G1 新規エンドポイントに認証系デコレータが無い
+ G2 認証系デコレータが削除された
+ G3 認証/認可デコレータのコメントアウトが増えた
+ G4 認可を左右する config が危険側に変わった
+ G5 ModelView の can_delete / can_export が False -> True
+ G6 属性不明のまま追加された経路がある(レビュー必須)
+"""
+import argparse
+import json
+import sys
+
+FAIL = 'FAIL'
+WARN = 'WARN'
+
+# G4: 危険側とみなす値
+DANGEROUS_VALUES = {
+ '*_PERMISSION_FACTORY': ('None',),
+ 'CSRF保護': ('False',),
+ '認証の全無効化': ('True',),
+}
+
+
+def load(path):
+ with open(path, encoding='utf-8') as f:
+ return json.load(f)
+
+
+def auth_of(e):
+ return set(e.get('auth_decorators') or [])
+
+
+def classify(old, new):
+ """エンドポイント単位の変化を分類する。"""
+ o, n = old['endpoints'], new['endpoints']
+ res = {k: [] for k in (
+ 'ADDED', 'REMOVED', 'RULE_CHANGED', 'METHODS_CHANGED',
+ 'AUTH_CHANGED', 'IMPL_CHANGED', 'ATTRS_UNKNOWN_NEW')}
+
+ for key in sorted(set(n) - set(o)):
+ e = n[key]
+ res['ADDED'].append({'key': key, 'new': e})
+ if e.get('attrs') == 'unknown':
+ res['ATTRS_UNKNOWN_NEW'].append({'key': key, 'new': e})
+ for key in sorted(set(o) - set(n)):
+ res['REMOVED'].append({'key': key, 'old': o[key]})
+
+ for key in sorted(set(o) & set(n)):
+ a, b = o[key], n[key]
+ if a.get('rules') != b.get('rules'):
+ res['RULE_CHANGED'].append({'key': key, 'old': a, 'new': b})
+ # メソッドはルール単位で比較する(endpoint 単位の union では取りこぼす)
+ am = {x['rule']: x['methods'] for x in a.get('routes', [])}
+ bm = {x['rule']: x['methods'] for x in b.get('routes', [])}
+ if any(am.get(k2) != bm.get(k2) for k2 in set(am) & set(bm)):
+ res['METHODS_CHANGED'].append({'key': key, 'old': a, 'new': b})
+ if a.get('auth_hash') != b.get('auth_hash'):
+ res['AUTH_CHANGED'].append({'key': key, 'old': a, 'new': b})
+ elif a.get('body_hash') != b.get('body_hash'):
+ res['IMPL_CHANGED'].append({'key': key, 'old': a, 'new': b})
+ # 属性が取れていたのに取れなくなった = 実装の持ち方が変わった
+ if a.get('attrs') == 'ast' and b.get('attrs') == 'unknown':
+ res['ATTRS_UNKNOWN_NEW'].append({'key': key, 'new': b})
+ return res
+
+
+def diff_modelviews(old, new):
+ o, n = old.get('modelviews', {}), new.get('modelviews', {})
+ added = [{'endpoint': k, 'new': n[k]} for k in sorted(set(n) - set(o))]
+ removed = [{'endpoint': k} for k in sorted(set(o) - set(n))]
+ flipped = []
+ for k in sorted(set(o) & set(n)):
+ for flag in ('can_create', 'can_edit', 'can_delete', 'can_export', 'can_view_details'):
+ if o[k].get(flag) is False and n[k].get(flag) is True:
+ flipped.append({'endpoint': k, 'flag': flag})
+ if o[k].get('column_export_list') != n[k].get('column_export_list'):
+ flipped.append({'endpoint': k, 'flag': 'column_export_list',
+ 'old': o[k].get('column_export_list'),
+ 'new': n[k].get('column_export_list')})
+ return added, removed, flipped
+
+
+def diff_config(old, new):
+ o, n = old.get('config', {}), new.get('config', {})
+ changed = []
+ for k in sorted(set(o) & set(n)):
+ if o[k]['value_hash'] != n[k]['value_hash']:
+ changed.append({'key': k, 'label': n[k]['label'],
+ 'old': o[k]['value'], 'new': n[k]['value']})
+ for k in sorted(set(n) - set(o)):
+ changed.append({'key': k, 'label': n[k]['label'], 'old': '(なし)', 'new': n[k]['value']})
+ for k in sorted(set(o) - set(n)):
+ changed.append({'key': k, 'label': o[k]['label'], 'old': o[k]['value'], 'new': '(削除)'})
+ return changed
+
+
+def diff_packages(old, new):
+ """依存パッケージの版変化。経路の増減を依存更新に帰着させるために取る。"""
+ o, n = old.get('packages', {}), new.get('packages', {})
+ out = []
+ for k in sorted(set(o) & set(n)):
+ if o[k] != n[k]:
+ out.append({'package': k, 'old': o[k], 'new': n[k]})
+ for k in sorted(set(n) - set(o)):
+ out.append({'package': k, 'old': '(なし)', 'new': n[k]})
+ for k in sorted(set(o) - set(n)):
+ out.append({'package': k, 'old': o[k], 'new': '(削除)'})
+ return out
+
+
+def provider_name(e):
+ p = (e or {}).get('provider') or ''
+ return p.split('==')[0]
+
+
+def diff_commented(old, new):
+ o, n = old.get('commented_auth', {}), new.get('commented_auth', {})
+ out = []
+ for mod in sorted(n):
+ before = {(c['line'], c['text']) for c in o.get(mod, [])}
+ before_text = {c['text'] for c in o.get(mod, [])}
+ for c in n[mod]:
+ if (c['line'], c['text']) not in before and c['text'] not in before_text:
+ out.append({'module': mod, **c})
+ return out
+
+
+def gates(res, mv_added, mv_flipped, conf_changed, commented_new, pkg_changed):
+ """(レベル, ゲートID, 説明, 該当リスト) を返す。"""
+ g = []
+
+ g1 = [x for x in res['ADDED']
+ if x['new'].get('attrs') == 'ast' and not auth_of(x['new'])]
+ if g1:
+ g.append((FAIL, 'G1', '新規エンドポイントに認証系デコレータが無い', g1))
+
+ g2 = [x for x in res['AUTH_CHANGED'] if auth_of(x['old']) - auth_of(x['new'])]
+ if g2:
+ g.append((FAIL, 'G2', '認証系デコレータが削除された', g2))
+
+ if commented_new:
+ g.append((FAIL, 'G3', '認証/認可デコレータのコメントアウトが増えた', commented_new))
+
+ g4 = [c for c in conf_changed
+ if c['new'] in DANGEROUS_VALUES.get(c['label'], ())]
+ if g4:
+ g.append((FAIL, 'G4', '認可を左右する config が危険側に変わった', g4))
+
+ g5 = [f for f in mv_flipped if f['flag'] in ('can_delete', 'can_export')]
+ if g5:
+ g.append((FAIL, 'G5', 'ModelView の can_delete / can_export が False -> True', g5))
+
+ g6 = res['ATTRS_UNKNOWN_NEW']
+ if g6:
+ g.append((FAIL, 'G6', '属性不明のまま追加された経路がある(手動レビュー必須)', g6))
+
+ # G7: 依存更新に伴う経路の増減。外部ライブラリ由来の経路は
+ # ソース走査では原理的に見えないため、実機ダンプでしか捕捉できない。
+ bumped = {c['package'] for c in pkg_changed}
+ g7 = [x for x in res['ADDED'] + res['REMOVED']
+ if provider_name(x.get('new') or x.get('old')) in bumped]
+ if g7:
+ g.append((FAIL, 'G7', '依存パッケージの更新で外部ライブラリ由来の経路が増減した', g7))
+
+ if mv_added:
+ g.append((WARN, 'W1', 'ModelView が追加された(1つにつき自動生成8ルート・削除系を含む)', mv_added))
+ if res['IMPL_CHANGED']:
+ g.append((WARN, 'W2', '実装本体が変化(data_op / 情報露出を再確認)', res['IMPL_CHANGED']))
+ if res['METHODS_CHANGED']:
+ g.append((WARN, 'W3', 'HTTPメソッドが増減した', res['METHODS_CHANGED']))
+ if res['RULE_CHANGED']:
+ g.append((WARN, 'W4', 'URL が変化した', res['RULE_CHANGED']))
+ if pkg_changed:
+ # 経路が変わらなくても、既存経路の挙動が変わっている可能性がある
+ g.append((WARN, 'W6', '依存パッケージの版が変化した', pkg_changed))
+ other_conf = [c for c in conf_changed if c not in g4]
+ if other_conf:
+ g.append((WARN, 'W5', '監視対象 config が変化した', other_conf))
+ return g
+
+
+def one_line(x):
+ # 依存パッケージの版変化
+ if 'package' in x:
+ return f"`{x['package']}` — {x['old']} -> {x['new']}"
+ # config 変化(label を持つ)
+ if 'label' in x:
+ return f"`{x.get('key')}` ({x['label']}) — `{x.get('old')}` -> `{x.get('new')}`"
+ # コメントアウト認証
+ if 'module' in x and 'line' in x:
+ return f"`{x['module']}:{x['line']}` — {x['text']}"
+ # ModelView フラグ変化
+ if 'flag' in x:
+ return (f"`{x['endpoint']}` — {x['flag']}: "
+ f"{x.get('old', False)} -> {x.get('new', True)}")
+ # ModelView 追加/削除
+ if 'endpoint' in x and 'key' not in x:
+ n = x.get('new') or {}
+ return (f"`{x['endpoint']}` — model={n.get('model')} "
+ f"del={n.get('can_delete')} exp={n.get('can_export')}")
+ # エンドポイント変化
+ e = x.get('new') or x.get('old') or {}
+ rules = ','.join(e.get('rules', []))[:70]
+ meth = ','.join(e.get('methods', []))
+ auth = ','.join(e.get('auth_decorators') or []) or '(なし)'
+ line = f"`{x['key']}` — {meth} {rules} — auth: {auth}"
+ old_e = x.get('old')
+ if old_e and x.get('new') and old_e.get('auth_decorators') != e.get('auth_decorators'):
+ before = ','.join(old_e.get('auth_decorators') or []) or '(なし)'
+ line += f" ← 旧: {before}"
+ if old_e and x.get('new') and old_e.get('rules') != e.get('rules'):
+ line += f" ← 旧URL: {','.join(old_e.get('rules', []))[:70]}"
+ if e.get('provider'):
+ line += f" [provider: {e['provider']}]"
+ if e.get('reason'):
+ line += f" [{e['reason']}]"
+ if e.get('impl'):
+ line += f" ({e['impl']})"
+ return line
+
+
+def render(old, new, res, mv, conf_changed, commented_new, pkg_changed, gate_list,
+ summary_only=False):
+ om, nm = old['meta'], new['meta']
+ L = []
+ L.append('# API インベントリ差分レポート')
+ L.append('')
+ L.append(f"- 旧: `{om.get('revision')}` {om.get('tag')} (profile={om.get('profile')}) "
+ f"endpoints={om['counts']['endpoints']} "
+ f"(外部ライブラリ由来 {om['counts'].get('external_endpoints', '?')})")
+ L.append(f"- 新: `{nm.get('revision')}` {nm.get('tag')} (profile={nm.get('profile')}) "
+ f"endpoints={nm['counts']['endpoints']} "
+ f"(外部ライブラリ由来 {nm['counts'].get('external_endpoints', '?')})")
+ if om.get('profile') != nm.get('profile'):
+ L.append('')
+ L.append('> **注意**: プロファイルが異なります。条件付き blueprint 登録の差が '
+ '追加/削除として現れるため、同一プロファイル同士で比較してください。')
+ L.append('')
+
+ fails = [g for g in gate_list if g[0] == FAIL]
+ warns = [g for g in gate_list if g[0] == WARN]
+ L.append(f"## 判定: {'❌ FAIL' if fails else '✅ PASS'} "
+ f"(FAIL {len(fails)} / WARN {len(warns)})")
+ L.append('')
+
+ L.append('## サマリ')
+ L.append('')
+ L.append('| 分類 | 件数 |')
+ L.append('|---|---:|')
+ for k, v in res.items():
+ L.append(f'| {k} | {len(v)} |')
+ L.append(f"| ModelView 追加 | {len(mv[0])} |")
+ L.append(f"| ModelView 削除 | {len(mv[1])} |")
+ L.append(f"| ModelView フラグ変化 | {len(mv[2])} |")
+ L.append(f'| config 変化 | {len(conf_changed)} |')
+ L.append(f'| コメントアウト認証の増加 | {len(commented_new)} |')
+ L.append(f'| 依存パッケージの版変化 | {len(pkg_changed)} |')
+ L.append('')
+
+ for level, gid, desc, items in gate_list:
+ L.append(f"## [{level}] {gid} {desc} — {len(items)}件")
+ L.append('')
+ if summary_only:
+ # 依存パッケージの版は公開情報なので名前を出してよい。
+ # 原因追跡に必要で、経路や所見は一切含まない。
+ if gid == 'W6':
+ for x in items[:40]:
+ L.append(f'- {one_line(x)}')
+ if len(items) > 40:
+ L.append(f'- … ほか {len(items) - 40} 件')
+ else:
+ L.append('> 件数のみ。該当の経路名は秘密側の完全版レポートを参照。')
+ L.append('')
+ continue
+ for x in items[:40]:
+ L.append(f'- {one_line(x)}')
+ if len(items) > 40:
+ L.append(f'- … ほか {len(items) - 40} 件')
+ L.append('')
+
+ if not gate_list:
+ L.append('変化はありません。')
+ L.append('')
+
+ L.append('---')
+ L.append('')
+ if summary_only:
+ return '\n'.join(L)
+ L.append('## 全分類の明細')
+ L.append('')
+ for k, v in res.items():
+ if not v:
+ continue
+ L.append(f'### {k} — {len(v)}件')
+ L.append('')
+ for x in v[:60]:
+ L.append(f'- {one_line(x)}')
+ if len(v) > 60:
+ L.append(f'- … ほか {len(v) - 60} 件')
+ L.append('')
+ return '\n'.join(L)
+
+
+def main():
+ p = argparse.ArgumentParser(description='API スナップショットの差分を取る')
+ p.add_argument('old')
+ p.add_argument('new')
+ p.add_argument('--out', help='Markdown 出力先(既定: 標準出力)')
+ p.add_argument('--json-out', help='機械可読な差分の出力先')
+ p.add_argument('--gate', action='store_true', help='FAIL があれば exit 1')
+ p.add_argument('--summary-only', action='store_true',
+ help='件数のみ出力する。public リポジトリの CI ログ/artifact/PRコメントは'
+ '誰でも読めるため、URI や endpoint 名を出さない')
+ a = p.parse_args()
+
+ old, new = load(a.old), load(a.new)
+ res = classify(old, new)
+ mv = diff_modelviews(old, new)
+ conf_changed = diff_config(old, new)
+ commented_new = diff_commented(old, new)
+ pkg_changed = diff_packages(old, new)
+ gate_list = gates(res, mv[0], mv[2], conf_changed, commented_new, pkg_changed)
+
+ md = render(old, new, res, mv, conf_changed, commented_new, pkg_changed, gate_list,
+ summary_only=a.summary_only)
+ if a.out:
+ with open(a.out, 'w', encoding='utf-8') as f:
+ f.write(md + '\n')
+ print(f'{a.out} を書き出しました')
+ else:
+ print(md)
+
+ if a.json_out:
+ with open(a.json_out, 'w', encoding='utf-8') as f:
+ json.dump({'endpoints': res, 'modelviews_added': mv[0],
+ 'modelviews_removed': mv[1], 'modelviews_flipped': mv[2],
+ 'config': conf_changed, 'commented_auth': commented_new,
+ 'packages': pkg_changed,
+ 'gates': [{'level': l, 'id': i, 'desc': d, 'count': len(x)}
+ for l, i, d, x in gate_list]},
+ f, ensure_ascii=False, indent=1)
+
+ fails = [g for g in gate_list if g[0] == FAIL]
+ for level, gid, desc, items in gate_list:
+ print(f'[{level}] {gid} {desc}: {len(items)}件', file=sys.stderr)
+ if a.gate and fails:
+ sys.exit(1)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/api-inventory/scripts/dump_modelviews.py b/tools/api-inventory/scripts/dump_modelviews.py
new file mode 100644
index 0000000000..884081134d
--- /dev/null
+++ b/tools/api-inventory/scripts/dump_modelviews.py
@@ -0,0 +1,16 @@
+from flask import current_app
+admin=current_app.extensions['admin'][0]
+out=[]
+for v in admin._views:
+ cls=type(v).__name__
+ ep=getattr(v,'endpoint',None)
+ model=getattr(getattr(v,'model',None),'__name__',None)
+ table=getattr(getattr(v,'model',None),'__tablename__',None)
+ can_c=getattr(v,'can_create',None); can_e=getattr(v,'can_edit',None)
+ can_d=getattr(v,'can_delete',None); can_x=getattr(v,'can_export',None)
+ can_vd=getattr(v,'can_view_details',None)
+ exp_list=getattr(v,'column_export_list',None)
+ if model: # ModelView only
+ out.append("\t".join(str(x) for x in [ep,cls,model,table,can_c,can_e,can_d,can_x,can_vd,exp_list]))
+open("/tmp/mv.tsv","w").write("\n".join(out))
+print("ModelViews:",len(out))
diff --git a/tools/api-inventory/scripts/enrich_git.py b/tools/api-inventory/scripts/enrich_git.py
new file mode 100644
index 0000000000..6845ca69cb
--- /dev/null
+++ b/tools/api-inventory/scripts/enrich_git.py
@@ -0,0 +1,96 @@
+# -*- coding: utf-8 -*-
+"""TSV の 36-39列 (last_commit / date / subject / release_tag) を git から埋める。
+
+使い方: python3 enrich_git.py
+- 14列目 impl_file (repo相対), 15列目 impl_line を見て、その行を含む
+ def/class の行範囲を AST で特定し `git log -1 -L a,b:file` で最終コミットを取る。
+- release_tag は `git tag --sort=creatordate --contains ` の先頭 (最初に入ったリリース)。
+"""
+import ast, os, subprocess, sys, functools
+
+ROOT = '/home/mhaya/wekov2'
+NCOL = 41
+
+@functools.lru_cache(maxsize=None)
+def def_ranges(path):
+ """ファイル内の全 def/class の (start, end) をリストで返す。"""
+ fp = os.path.join(ROOT, path)
+ if not os.path.isfile(fp):
+ return ()
+ try:
+ tree = ast.parse(open(fp, encoding='utf-8', errors='replace').read())
+ except Exception:
+ return ()
+ out = []
+ for n in ast.walk(tree):
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
+ s = min([n.lineno] + [d.lineno for d in n.decorator_list])
+ out.append((s, getattr(n, 'end_lineno', n.lineno)))
+ return tuple(out)
+
+def enclosing(path, line):
+ """line を含む最小の def/class 範囲。無ければ (line, line)。"""
+ best = None
+ for s, e in def_ranges(path):
+ if s <= line <= e:
+ if best is None or (e - s) < (best[1] - best[0]):
+ best = (s, e)
+ return best or (line, line)
+
+@functools.lru_cache(maxsize=None)
+def git_last(path, start, end):
+ try:
+ r = subprocess.run(
+ ['git', '-C', ROOT, 'log', '-1', '--format=%h\x1f%ad\x1f%s', '--date=short',
+ '-L', f'{start},{end}:{path}'],
+ capture_output=True, text=True, timeout=60)
+ except Exception:
+ return ('', '', '')
+ line = r.stdout.split('\n', 1)[0] if r.stdout else ''
+ parts = line.split('\x1f')
+ if len(parts) != 3:
+ return ('', '', '')
+ subj = parts[2].replace('\t', ' ').strip()
+ return (parts[0], parts[1], subj[:120])
+
+@functools.lru_cache(maxsize=None)
+def git_tag(sha):
+ if not sha:
+ return ''
+ r = subprocess.run(['git', '-C', ROOT, 'tag', '--sort=creatordate', '--contains', sha],
+ capture_output=True, text=True, timeout=60)
+ tags = [t for t in r.stdout.split('\n') if t.strip()]
+ return tags[0] if tags else '(未リリース)'
+
+def main(src, dst):
+ out = []
+ bad = 0
+ for i, raw in enumerate(open(src, encoding='utf-8'), 1):
+ raw = raw.rstrip('\n')
+ if not raw.strip():
+ continue
+ c = raw.split('\t')
+ if len(c) < NCOL:
+ c += [''] * (NCOL - len(c))
+ elif len(c) > NCOL:
+ sys.stderr.write(f'WARN line {i}: {len(c)} cols (>41), truncating tail into notes\n')
+ c = c[:NCOL - 1] + [' | '.join(c[NCOL - 1:])]
+ bad += 1
+ path, ln = c[13].strip(), c[14].strip()
+ sha = date = subj = tag = ''
+ try:
+ line = int(ln)
+ except ValueError:
+ line = None
+ if path and line and os.path.isfile(os.path.join(ROOT, path)):
+ s, e = enclosing(path, line)
+ sha, date, subj = git_last(path, s, e)
+ tag = git_tag(sha)
+ c[35], c[36], c[37], c[38] = sha or '-', date or '-', subj or '-', tag or '-'
+ out.append('\t'.join(x.replace('\t', ' ') for x in c))
+ with open(dst, 'w', encoding='utf-8') as f:
+ f.write('\n'.join(out) + '\n')
+ print(f'rows={len(out)} col_fixups={bad}')
+
+if __name__ == '__main__':
+ main(sys.argv[1], sys.argv[2])
diff --git a/tools/api-inventory/scripts/extract_endpoints.py b/tools/api-inventory/scripts/extract_endpoints.py
new file mode 100644
index 0000000000..0575d91ab8
--- /dev/null
+++ b/tools/api-inventory/scripts/extract_endpoints.py
@@ -0,0 +1,36 @@
+import ast, os, json, sys
+ROOT='/home/mhaya/wekov2'
+targets=[]
+for dp,dn,fn in os.walk(os.path.join(ROOT,'modules')):
+ if any(s in dp+'/' for s in ('/tests','/examples','/.tox','/node_modules','/cookiecutter')): continue
+ if 'config.py' in fn: targets.append(os.path.join(dp,'config.py'))
+out={}
+for fp in sorted(targets):
+ rel=os.path.relpath(fp,ROOT)
+ try: tree=ast.parse(open(fp,encoding='utf-8',errors='replace').read())
+ except Exception: continue
+ for n in tree.body:
+ if isinstance(n,ast.Assign) and len(n.targets)==1 and isinstance(n.targets[0],ast.Name):
+ name=n.targets[0].id
+ if 'REST_ENDPOINTS' not in name and 'ENDPOINTS' not in name: continue
+ try: val=ast.literal_eval(n.value)
+ except Exception:
+ # dict(...) call
+ val=None
+ if isinstance(n.value,ast.Call) and getattr(n.value.func,'id','')=='dict':
+ val={}
+ for k in n.value.keywords:
+ try: val[k.arg]=ast.literal_eval(k.value)
+ except Exception: val[k.arg]=''
+ if val is None: val=''
+ out.setdefault(rel,{})[name]={'line':n.lineno,'value':val}
+json.dump(out,open(sys.argv[1],'w',encoding='utf-8'),ensure_ascii=False,indent=1,default=str)
+for f,d in out.items():
+ for name,v in d.items():
+ val=v['value']
+ if isinstance(val,dict):
+ for k,vv in val.items():
+ r = vv.get('route') if isinstance(vv,dict) else None
+ print(f"{f}:{v['line']}\t{name}\t{k}\t{r}")
+ else:
+ print(f"{f}:{v['line']}\t{name}\t")
diff --git a/tools/api-inventory/scripts/extract_routes.py b/tools/api-inventory/scripts/extract_routes.py
new file mode 100644
index 0000000000..71b2c1b390
--- /dev/null
+++ b/tools/api-inventory/scripts/extract_routes.py
@@ -0,0 +1,180 @@
+# -*- coding: utf-8 -*-
+"""WEKO3 全ルート構造抽出 (AST)."""
+import ast, os, json, sys, re
+
+import os as _os
+ROOT = _os.environ.get('WEKO_ROOT', _os.path.abspath(_os.path.join(_os.path.dirname(__file__), '..', '..')))
+SKIP = ('/tests', '/examples', '/.tox', '/node_modules', '/docs/', '/build/', '/cookiecutter')
+
+def iter_py():
+ for dp, dn, fn in os.walk(os.path.join(ROOT, 'modules')):
+ if any(s in dp + '/' for s in SKIP):
+ continue
+ for f in sorted(fn):
+ if f.endswith('.py'):
+ yield os.path.join(dp, f)
+
+def lit(node):
+ try:
+ return ast.literal_eval(node)
+ except Exception:
+ return None
+
+def src_of(node):
+ return getattr(node, 'lineno', None), getattr(node, 'end_lineno', None)
+
+def dec_name(d):
+ c = d.func if isinstance(d, ast.Call) else d
+ parts = []
+ while isinstance(c, ast.Attribute):
+ parts.append(c.attr); c = c.value
+ if isinstance(c, ast.Name):
+ parts.append(c.id)
+ return '.'.join(reversed(parts))
+
+def dec_repr(d):
+ name = dec_name(d)
+ if isinstance(d, ast.Call):
+ args = []
+ for a in d.args:
+ v = lit(a)
+ args.append(repr(v) if v is not None else ast.unparse(a))
+ for k in d.keywords:
+ v = lit(k.value)
+ args.append(f"{k.arg}={v!r}" if v is not None else f"{k.arg}={ast.unparse(k.value)}")
+ return f"{name}({', '.join(args)})"
+ return name
+
+def body_facts(node, src_lines):
+ """関数本体からの機械的事実."""
+ raises, excepts, calls = set(), set(), set()
+ aborts = set()
+ for n in ast.walk(node):
+ if isinstance(n, ast.Raise) and n.exc is not None:
+ e = n.exc.func if isinstance(n.exc, ast.Call) else n.exc
+ nm = dec_name(e) if not isinstance(e, ast.Name) else e.id
+ if nm: raises.add(nm)
+ if isinstance(n, ast.ExceptHandler) and n.type is not None:
+ t = n.type
+ if isinstance(t, ast.Tuple):
+ for x in t.elts:
+ excepts.add(dec_name(x) or getattr(x, 'id', ''))
+ else:
+ excepts.add(dec_name(t) or getattr(t, 'id', ''))
+ if isinstance(n, ast.Call):
+ nm = dec_name(n.func)
+ if nm: calls.add(nm)
+ if nm == 'abort':
+ v = lit(n.args[0]) if n.args else None
+ if v is not None: aborts.add(str(v))
+ return {
+ 'raises': sorted(x for x in raises if x),
+ 'excepts': sorted(x for x in excepts if x),
+ 'aborts': sorted(aborts),
+ 'calls': sorted(calls),
+ }
+
+DB_PAT = re.compile(r'db\.session\.(add|commit|delete|merge|bulk)|\.query\.|session\.execute')
+ES_PAT = re.compile(r'RecordsSearch|search_index|\bes\.|indexer|Elasticsearch|_search|percolat')
+REDIS_PAT = re.compile(r'RedisConnection|current_cache|redis')
+MAIL_PAT = re.compile(r'send_mail|send_email|MailSettingView|flask_mail|send_request_mail')
+TASK_PAT = re.compile(r'\.delay\(|\.apply_async\(|celery')
+FILE_PAT = re.compile(r'ObjectVersion|FileInstance|Bucket|tempfile|open\(|send_file|Location')
+EXT_PAT = re.compile(r'requests\.(get|post|put|delete)|urlopen|HandleClient|http[s]?://')
+
+def store_hints(seg):
+ h = []
+ if DB_PAT.search(seg): h.append('DB')
+ if ES_PAT.search(seg): h.append('ES')
+ if REDIS_PAT.search(seg): h.append('Redis')
+ if FILE_PAT.search(seg): h.append('File')
+ if MAIL_PAT.search(seg): h.append('Mail')
+ if TASK_PAT.search(seg): h.append('Celery')
+ if EXT_PAT.search(seg): h.append('External')
+ return h
+
+REQ_PAT = re.compile(r"request\.(?:args|form|values|json|get_json|headers|files|data|cookies)(?:\.get\(\s*['\"]([^'\"]+)['\"])?")
+
+def request_fields(seg):
+ out = set()
+ for m in re.finditer(r"request\.(args|form|values|json|headers|files|cookies)(?:\.get\(\s*['\"]([^'\"]+)['\"]|\[\s*['\"]([^'\"]+)['\"]\s*\])?", seg):
+ kind = m.group(1); key = m.group(2) or m.group(3)
+ out.add(f"{kind}:{key}" if key else f"{kind}:*")
+ if 'get_json' in seg or 'request.json' in seg or 'request.data' in seg:
+ out.add('body:json')
+ return sorted(out)
+
+results = []
+blueprints = []
+
+for fp in iter_py():
+ rel = os.path.relpath(fp, ROOT)
+ try:
+ text = open(fp, encoding='utf-8', errors='replace').read()
+ tree = ast.parse(text)
+ except Exception as e:
+ continue
+ lines = text.splitlines()
+
+ # Blueprint definitions
+ for n in ast.walk(tree):
+ if isinstance(n, ast.Assign) and isinstance(n.value, ast.Call):
+ fn = dec_name(n.value.func)
+ if fn.endswith('Blueprint'):
+ var = ast.unparse(n.targets[0]) if n.targets else '?'
+ bpname = lit(n.value.args[0]) if n.value.args else None
+ kw = {k.arg: lit(k.value) for k in n.value.keywords}
+ blueprints.append({'file': rel, 'line': n.lineno, 'var': var,
+ 'name': bpname, 'url_prefix': kw.get('url_prefix'),
+ 'static_folder': kw.get('static_folder')})
+
+ # route decorators
+ for n in ast.walk(tree):
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ for d in n.decorator_list:
+ c = d.func if isinstance(d, ast.Call) else d
+ if isinstance(c, ast.Attribute) and c.attr == 'route':
+ bpvar = ast.unparse(c.value)
+ route = lit(d.args[0]) if d.args else None
+ methods = None
+ for k in d.keywords:
+ if k.arg == 'methods': methods = lit(k.value)
+ if k.arg == 'endpoint': pass
+ s, e = src_of(n)
+ seg = '\n'.join(lines[s-1:e]) if s and e else ''
+ results.append({
+ 'kind': 'route',
+ 'file': rel, 'line': n.lineno, 'end_line': e,
+ 'bp_var': bpvar, 'route': route,
+ 'methods': methods or ['GET'],
+ 'func': n.name,
+ 'decorators': [dec_repr(x) for x in n.decorator_list],
+ 'doc': (ast.get_docstring(n) or '').strip().splitlines()[:3],
+ 'facts': body_facts(n, lines),
+ 'stores': store_hints(seg),
+ 'req': request_fields(seg),
+ })
+
+ # add_url_rule calls
+ for n in ast.walk(tree):
+ if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) and n.func.attr == 'add_url_rule':
+ bpvar = ast.unparse(n.func.value)
+ route = lit(n.args[0]) if n.args else None
+ route_expr = ast.unparse(n.args[0]) if n.args else None
+ kw = {}
+ for k in n.keywords:
+ kw[k.arg] = ast.unparse(k.value)
+ if k.arg == 'rule': route_expr = ast.unparse(k.value); route = lit(k.value)
+ if k.arg == 'methods':
+ kw['methods'] = lit(k.value)
+ results.append({
+ 'kind': 'add_url_rule',
+ 'file': rel, 'line': n.lineno,
+ 'bp_var': bpvar, 'route': route, 'route_expr': route_expr,
+ 'view_func': kw.get('view_func'), 'methods': kw.get('methods'),
+ 'endpoint': kw.get('endpoint'),
+ })
+
+out = {'routes': results, 'blueprints': blueprints}
+json.dump(out, open(sys.argv[1], 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
+print('routes:', len(results), 'blueprints:', len(blueprints))
diff --git a/tools/api-inventory/scripts/final_apply.py b/tools/api-inventory/scripts/final_apply.py
new file mode 100644
index 0000000000..f8c99c0e20
--- /dev/null
+++ b/tools/api-inventory/scripts/final_apply.py
@@ -0,0 +1,90 @@
+# [参考実装] 動的検証用。セッション固有の絶対パス(scratchpad/ck等)を含むため、
+# 再利用時は BASE/HOST/Cookie保存先/probe対象TSVパスを環境に合わせて修正すること。
+# 手順は tools/api-inventory/README.md の Phase 3 を参照。
+import json,re,sys,collections
+R="/home/mhaya/wekov2/"; P="/tmp/claude-1000/-home-mhaya-wekov2/a8119b60-023e-4882-84ac-a0edcfb5627e/scratchpad/api"
+def load(p): return [l.rstrip("\n").split("\t") for l in open(p,encoding="utf-8") if l.rstrip("\n")]
+rows=load(R+"weko3_api_list.tsv"); hd=rows[0]; data=rows[1:]
+res=json.load(open(P+"/probe_results2.json"))
+byp=set()
+for l in open(P+"/status500_verdict.tsv",encoding="utf-8"):
+ a=l.rstrip("\n").split("\t")
+ if len(a)>=3 and "到達" in a[2]: byp.add((a[0],a[1]))
+def resolve(uri):
+ u=uri
+ for pat,val in [(r"","v1"),(r"","x:y:secret.png"),(r"<[^>]*api_code>","crf"),
+ (r"<[^>]*index_id>","100100"),(r"<[^>]*journal_id>","1"),(r"","top_page_access"),
+ (r"","2026"),(r"","8"),(r"<[^>]*(file_name|filename|key)>","secret.png"),
+ (r"]+>","secret.png"),(r"<[^>]*(pid_value|recid|identifier)>","1001"),
+ (r"<[^>]*activity_id>","A-1"),(r"<[^>]*community_id>","comm1"),(r"<[^>]*(id|Id)>","1"),(r"<[^>]+>","1")]:
+ u=re.sub(pat,val,u)
+ return u
+def kind(st,mth,url):
+ if st in("302","401","403"): return "遮断"
+ if st=="500": return "到達" if (mth,url) in byp else "遮断"
+ if st in("404","405"): return "検証不能"
+ if st in("000","ERR"): return "無応答"
+ if st and st[0] in "245": return "到達"
+ return "?"
+# 25補正(正URLで再測済み) endpoint+method -> 文字列
+fix={
+ ("invenio_communities_rest.communities_item","GET"):"未認証で到達|anon=200; general=200 ※コミュニティ詳細を未認証取得",
+ ("invenio_files_rest.object_api","GET"):"測定範囲では遮断|anon=404(files-rest hidden権限拒否)",
+ ("invenio_files_rest.object_api","DELETE"):"測定範囲では遮断|anon=404(hidden)",
+ ("invenio_files_rest.object_api","POST"):"測定範囲では遮断|anon=404(hidden)",
+ ("invenio_files_rest.object_api","PUT"):"測定範囲では遮断|anon=404(hidden)",
+ ("invenio_files_rest.object_thumbnail_api","GET"):"到達|anon=500(認可通過後クラッシュ)",
+ ("invenio_files_rest.object_thumbnail_api","POST"):"到達|anon=500(handler)",
+ ("invenio_files_rest.object_thumbnail_api","PUT"):"到達|anon=500(handler)",
+ ("invenio_files_rest.object_thumbnail_api","DELETE"):"到達|anon=500(handler)",
+ ("invenio_records_rest.recid_suggest","GET"):"経路なし(404)=SuggestResource未登録。指摘『未使用』を裏付け",
+ ("weko_groups.manage","GET"):"測定範囲では遮断|anon=302; general=302",
+ ("weko_groups.accept","POST"):"測定範囲では遮断|anon=302; general=302",
+ ("weko_groups.new_member","POST"):"測定範囲では遮断|anon=302; general=302",
+ ("invenio_deposit_rest.depid_actions","POST"):"検証不能(実URL要確認)",
+ ("community.edit_view","GET"):"検証不能(ModelView実URL要確認・admin-role-table対象)",
+ ("weko_plugins.setting","GET"):"検証不能(実URL要確認)",
+ ("pluginsetting.disable","GET"):"検証不能(実URL要確認)",
+ ("pluginsetting.enable","GET"):"検証不能(実URL要確認)",
+ ("pluginsetting.delete","GET"):"検証不能(実URL要確認)",
+}
+resync={"invenio_resourcesyncserver.file_content","invenio_resourcesyncserver.resource_dump_manifest",
+ "invenio_resourcesyncserver.change_list","invenio_resourcesyncserver.change_dump",
+ "invenio_resourcesyncserver.change_dump_manifest","invenio_resourcesyncserver.change_dump_content"}
+conf=[("/record//publish","POST","★E2E確定:未認証でpublish_status 0→1改変(recid1003・DB確認)"),
+ ("/api/iiif/v2/","GET","★確定:未認証で非公開ファイル画像取得(image/png 200)"),
+ ("get_curr_api_cert","GET","★確定:未認証でcert_data(account+password)漏洩"),
+ ("validate_user_info","POST","★確定:未認証でuser_id/email返却"),
+ ("/api/schemas/","POST","★確定:未認証でスキーマ作成201"),
+ ("/api/schemas/put/","PUT","★確定:未認証で任意パス名ファイル書込200"),
+ ("/api/records/","PUT","★確定:update factory=None素通り実行"),
+ ("/api/deposits/publish/","PUT","★確定:未認証でpublish handler到達"),]
+DVI=45; summ=collections.Counter()
+for i,c in enumerate(data):
+ if str(i) not in res: continue
+ ep=c[11]; mth=(c[4] or "GET").split(",")[0]; url=resolve(c[5]); r=res[str(i)]
+ kb=(ep,mth)
+ if kb in fix: body="[実測·正URL] "+fix[kb]
+ elif ep in resync: body="[実測·正URL] 検証不能(resync実URL要確認)"
+ else:
+ kinds={};parts=[]
+ for ident in("anon","general","comadmin","repoadmin"):
+ if ident in r: k=kind(r[ident],mth,url);kinds[ident]=k;parts.append(f"{ident}={r[ident]}({k})")
+ if kinds.get("anon")=="到達":v="未認証で到達"
+ elif kinds.get("general")=="到達":v="ログインのみで到達"
+ elif kinds.get("comadmin")=="到達":v="Community管理者で到達"
+ elif kinds.get("repoadmin")=="到達":v="Repository管理者で到達"
+ elif "検証不能" in kinds.values() and "到達" not in kinds.values():v="検証不能(テストURL未解決)"
+ else:v="測定範囲では遮断"
+ body=f"[実測] {v} | "+"; ".join(parts)
+ # ★確定を前置
+ for us,mm,note in conf:
+ if us in c[5] and mm in c[4]: body=note+" || "+body; break
+ while len(c)<=DVI:c.append("-")
+ c[DVI]=body
+ # サマリ
+ for kk in("★確定","未認証で到達","ログインのみで到達","Community管理者で到達","Repository管理者で到達","測定範囲では遮断","検証不能","経路なし","到達"):
+ if kk in body: summ[kk]+=1;break
+open(R+"weko3_api_list.tsv","w",encoding="utf-8").write("\t".join(hd)+"\n"+"\n".join("\t".join(x.replace("\t"," ") for x in c) for c in data)+"\n")
+print("=== 最終実測サマリ(223) ==="); [print(f" {n:4d} {k}") for k,n in summ.most_common()]
+print("列数:",len(hd))
diff --git a/tools/api-inventory/scripts/fixtures.py b/tools/api-inventory/scripts/fixtures.py
new file mode 100644
index 0000000000..d622820886
--- /dev/null
+++ b/tools/api-inventory/scripts/fixtures.py
@@ -0,0 +1,351 @@
+# -*- coding: utf-8 -*-
+"""Phase 7-a: 動的検証(probe)に必要な最小テストコーパスを実機に投入する。
+
+ python3 fixtures.py --out fixtures.json
+
+`install.sh` は `populate-instance.sh:179` の `demo init` がコメントアウトされているため、
+ロール・ユーザ・アイテムタイプ・インデックスツリーは入るが **レコードが0件**になる。
+このままでは到達可否(dynamic_verified)を測れない。本スクリプトが最小限を補う。
+
+投入するもの:
+ - 既知パスワードのユーザ(既存アカウントのパスワードを揃える)
+ - 公開インデックス
+ - 公開アイテム / 非公開アイテム / 他人所有の非公開アイテム(いずれもファイル実体付き)
+ - 全スコープの個人アクセストークン
+ - Community / Group(担当外リソースの越境検証用)
+
+生成したIDは fixtures.json に書き出し、probe.py がプレースホルダ解決に使う。
+
+冪等: 既存の値があれば再利用し、無ければ作る。CI で毎回流してよい。
+"""
+import argparse
+import json
+import os
+import subprocess
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from snapshot import resolve_container, sh # noqa: E402 同ディレクトリのヘルパを再利用
+
+PASSWORD = 'Passw0rd!123'
+
+PAYLOAD = r'''
+# -*- coding: utf-8 -*-
+import base64, json, io, traceback, uuid as _uuid
+from flask import current_app
+from invenio_db import db
+
+PASSWORD = "%(password)s"
+OUT = {"password": PASSWORD, "users": {}, "records": {}, "file": {},
+ "index": None, "token": None, "community": None, "group": None,
+ "errors": []}
+
+# 1x1 透明PNG(75B)。ファイル露出検証はバイト列が取れれば十分
+PNG = base64.b64decode(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk"
+ "YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==")
+
+
+def step(name):
+ """各投入を独立させる。1つ失敗しても残りは進める。"""
+ def deco(fn):
+ try:
+ fn()
+ db.session.commit()
+ except Exception as exc:
+ db.session.rollback()
+ OUT["errors"].append("%%s: %%s: %%s" %% (name, type(exc).__name__, exc))
+ traceback.print_exc()
+ return fn
+ return deco
+
+
+# ---------------------------------------------------------------- ユーザ
+@step("users")
+def _users():
+ from invenio_accounts.models import User
+ from flask_security.utils import hash_password
+ for email in ["wekosoftware@nii.ac.jp", "repoadmin@example.org",
+ "contributor@example.org", "user@example.org",
+ "comadmin@example.org"]:
+ u = User.query.filter_by(email=email).one_or_none()
+ if u is None:
+ continue
+ u.password = hash_password(PASSWORD)
+ u.active = True
+ db.session.add(u)
+ roles = [r.name for r in u.roles]
+ OUT["users"][email] = {"id": u.id, "roles": roles}
+
+
+# ---------------------------------------------------------------- インデックス
+@step("index")
+def _index():
+ from weko_index_tree.models import Index
+ idx = Index.query.filter_by(index_name_english="APIInventory Public").one_or_none()
+ if idx is None:
+ # (parent, position) に一意制約(uix_position)があるため空き position を取る
+ used = [i.position for i in Index.query.filter_by(parent=0).all()]
+ pos = (max(used) + 1) if used else 0
+ idx = Index(id=900001, parent=0, position=pos,
+ index_name="APIインベントリ検証用", index_name_english="APIInventory Public",
+ public_state=True, harvest_public_state=True,
+ browsing_role="3,-98,-99", contribute_role="1,2,3,4,-98,-99",
+ public_date=None, owner_user_id=1)
+ db.session.add(idx)
+ db.session.flush()
+ OUT["index"] = int(idx.id)
+
+
+# ---------------------------------------------------------------- レコード
+def _make_record(recid, owner_id, publish_status, title, with_file):
+ """PID(recid/depid/parent)+RecordMetadata+バケットを作る。
+
+ 合成レコードなのでアイテムタイプ固有フィールドは入れていない。
+ 詳細画面のレンダリングは 500 になりうるが、認可を通過したか(到達したか)の
+ 判定はできる。
+ """
+ from invenio_pidstore.models import PersistentIdentifier, PIDStatus
+ from invenio_records.models import RecordMetadata
+ from invenio_files_rest.models import Location, Bucket, ObjectVersion
+ from invenio_records_files.models import RecordsBuckets
+
+ existing = PersistentIdentifier.query.filter_by(
+ pid_type="recid", pid_value=str(recid)).one_or_none()
+ if existing is not None:
+ # 冪等かつ自己修復: 既存を再利用しつつ、重要フィールドは毎回入れ直す。
+ # 先行するステップ(インデックス作成など)が失敗した回に作られたレコードは
+ # path が空のままになり、check_index_permissions が通らず公開アイテムでも
+ # 未認証で読めない。再実行で直るようにする。
+ from sqlalchemy.orm.attributes import flag_modified
+ rm = RecordMetadata.query.get(existing.object_uuid)
+ js = dict(rm.json or {})
+ js["path"] = [str(OUT["index"])] if OUT["index"] else js.get("path", [])
+ js["owner"] = str(owner_id)
+ js["publish_status"] = publish_status
+ js.setdefault("pubdate", {"attribute_name": "PubDate",
+ "attribute_value": "2026-01-01"})
+ rm.json = js
+ flag_modified(rm, "json")
+ db.session.add(rm)
+ rb = RecordsBuckets.query.filter_by(record_id=rm.id).first()
+ bucket_id = str(rb.bucket_id) if rb else None
+ finfo = None
+ if rb is not None:
+ ov = ObjectVersion.query.filter_by(
+ bucket_id=rb.bucket_id, key="secret.png", is_head=True).first()
+ if ov is not None:
+ finfo = _file_info(rb.bucket_id, ov)
+ return rm, bucket_id, finfo
+
+ uid = _uuid.uuid4()
+ loc = Location.get_default() or Location.query.first()
+ bucket = Bucket.create(loc)
+ db.session.flush()
+
+ js = {
+ "_oai": {"id": "oai:weko3.example.org:%%08d" %% recid},
+ "path": [str(OUT["index"])] if OUT["index"] else [],
+ "owner": str(owner_id),
+ "recid": str(recid),
+ "title": [title],
+ "item_title": title,
+ "item_type_id": "1",
+ "pubdate": {"attribute_name": "PubDate", "attribute_value": "2026-01-01"},
+ "publish_date": "2026-01-01",
+ "publish_status": publish_status,
+ "weko_shared_ids": [],
+ "_buckets": {"deposit": str(bucket.id)},
+ "_deposit": {
+ "id": str(recid),
+ "pid": {"type": "depid", "value": str(recid), "revision_id": 0},
+ "owner": str(owner_id), "owners": [owner_id],
+ "created_by": owner_id, "status": "published",
+ },
+ }
+ rm = RecordMetadata(id=uid, json=js, version_id=1)
+ db.session.add(rm)
+ db.session.flush()
+ RecordsBuckets.create(record=rm, bucket=bucket)
+
+ for t, v in (("recid", str(recid)), ("depid", str(recid)),
+ ("parent", "parent:%%s" %% recid)):
+ if PersistentIdentifier.query.filter_by(pid_type=t, pid_value=v).one_or_none() is None:
+ PersistentIdentifier.create(t, v, object_type="rec", object_uuid=uid,
+ status=PIDStatus.REGISTERED)
+
+ finfo = None
+ if with_file:
+ ov = ObjectVersion.create(bucket, "secret.png", stream=io.BytesIO(PNG),
+ size=len(PNG))
+ db.session.flush()
+ finfo = _file_info(bucket.id, ov)
+ return rm, str(bucket.id), finfo
+
+
+def _file_info(bucket_id, ov):
+ """IIIF は bucket:version:key の三つ組を要求するのでそれも組み立てる。"""
+ return {"key": ov.key,
+ "bucket": str(bucket_id),
+ "version_id": str(ov.version_id),
+ "uuid_triplet": "%%s:%%s:%%s" %% (bucket_id, ov.version_id, ov.key),
+ "size": ov.file.size if ov.file else None}
+
+
+@step("records")
+def _records():
+ owner_c = OUT["users"].get("contributor@example.org", {}).get("id", 3)
+ owner_u = OUT["users"].get("user@example.org", {}).get("id", 4)
+ specs = [
+ ("public", 900001, owner_c, "0", "APIInventory 公開アイテム", False),
+ ("private", 900002, owner_c, "1", "APIInventory 非公開アイテム", True),
+ ("other_owner", 900003, owner_u, "1", "APIInventory 他人所有", True),
+ ]
+ for name, recid, owner, pub, title, with_file in specs:
+ rm, bucket, finfo = _make_record(recid, owner, pub, title, with_file)
+ OUT["records"][name] = {"recid": recid, "uuid": str(rm.id),
+ "owner": owner, "publish_status": pub,
+ "bucket": bucket, "file": finfo}
+ # 非公開アイテムのファイルが露出検証(IIIF/files-rest)の主対象
+ if finfo and name == "private":
+ OUT["file"] = finfo
+
+
+# ---------------------------------------------------------------- PIDVersioning
+@step("pid_versioning")
+def _versioning():
+ """親PIDから last_child が解決する状態にする(records系の到達に必要)。"""
+ from invenio_pidstore.models import PersistentIdentifier
+ from invenio_pidrelations.contrib.versioning import PIDVersioning
+ for r in OUT["records"].values():
+ child = PersistentIdentifier.query.filter_by(
+ pid_type="recid", pid_value=str(r["recid"])).one()
+ parent = PersistentIdentifier.query.filter_by(
+ pid_type="parent", pid_value="parent:%%s" %% r["recid"]).one()
+ pv = PIDVersioning(parent=parent)
+ if pv.last_child is None:
+ pv.insert_child(child)
+
+
+# ---------------------------------------------------------------- OAuthトークン
+@step("oauth_token")
+def _token():
+ from invenio_oauth2server.models import Token
+ uid = OUT["users"].get("contributor@example.org", {}).get("id", 3)
+ scopes = list(current_app.extensions["invenio-oauth2server"].scopes.keys())
+ t = Token.create_personal("api-inventory-probe", uid, scopes=scopes,
+ is_internal=True)
+ OUT["token"] = {"access_token": t.access_token, "user_id": uid,
+ "scopes": scopes}
+
+
+# ---------------------------------------------------------------- Community
+@step("community")
+def _community():
+ from invenio_communities.models import Community
+ from invenio_accounts.models import Role
+ from weko_index_tree.models import Index
+ cid = "apiinv"
+ c = Community.query.get(cid)
+ if c is None:
+ role = Role.query.filter_by(name="Community Administrator").first()
+ owner = OUT["users"].get("comadmin@example.org", {}).get("id", 5)
+ # root_node_id は NOT NULL。専用インデックスが作れていなければ既存を使う
+ root = OUT["index"]
+ if root is None:
+ any_idx = Index.query.first()
+ root = int(any_idx.id) if any_idx else None
+ if root is None:
+ raise RuntimeError("インデックスが1件も無いため Community を作れない")
+ c = Community(id=cid, id_role=(role.id if role else None), id_user=owner,
+ title="APIInventory Community", description="probe用",
+ root_node_id=root)
+ db.session.add(c)
+ db.session.flush()
+ OUT["community"] = {"id": cid, "owner": c.id_user}
+
+
+# ---------------------------------------------------------------- Group
+@step("group")
+def _group():
+ from weko_groups.models import Group
+ from invenio_accounts.models import User
+ name = "APIInventory Group"
+ g = Group.query.filter_by(name=name).one_or_none()
+ if g is None:
+ admin = User.query.filter_by(email="contributor@example.org").one_or_none()
+ g = Group.create(name=name, description="probe用",
+ admins=[admin] if admin else [])
+ OUT["group"] = {"id": g.id, "name": name}
+
+
+# ---------------------------------------------------------------- ES反映
+@step("reindex")
+def _reindex():
+ from invenio_indexer.api import RecordIndexer
+ from invenio_records.api import Record
+ import uuid as _u
+ idx = RecordIndexer()
+ for r in OUT["records"].values():
+ try:
+ idx.index(Record.get_record(_u.UUID(r["uuid"])))
+ except Exception as exc:
+ # ES反映は検索系エンドポイントの hits にしか効かない。
+ # 到達可否(dynamic_verified)の判定には不要なので失敗しても続行する。
+ OUT["errors"].append("reindex %%s(非致命): %%s" %% (r["recid"], exc))
+
+
+json.dump(OUT, open("/tmp/_fixtures.json", "w"), ensure_ascii=False, indent=1)
+print("FIXTURES_OK errors=%%d" %% len(OUT["errors"]))
+for e in OUT["errors"]:
+ print(" ERR", e)
+'''
+
+
+def main():
+ p = argparse.ArgumentParser(description='動的検証用フィクスチャを投入する')
+ p.add_argument('--out', default=os.path.join(
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'fixtures.json'))
+ p.add_argument('--container', default='',
+ help='投入先コンテナ(省略時は compose ラベルから自動検出)')
+ p.add_argument('--password', default=PASSWORD)
+ a = p.parse_args()
+
+ container = resolve_container(a.container)
+ import tempfile
+ work = tempfile.mkdtemp(prefix='api-fixtures-')
+ local = os.path.join(work, '_fixtures_payload.py')
+ with open(local, 'w', encoding='utf-8') as f:
+ f.write(PAYLOAD % {'password': a.password})
+
+ r = sh(['docker', 'cp', local, f'{container}:/tmp/_fixtures_payload.py'])
+ if r.returncode:
+ sys.exit(f'docker cp 失敗: {r.stderr.strip()}')
+ r = sh([
+ 'docker', 'exec', container, 'bash', '-lc',
+ 'source ~/.virtualenvs/invenio/bin/activate; cd /code; '
+ 'invenio shell -c "exec(open(\'/tmp/_fixtures_payload.py\').read())"',
+ ])
+ if 'FIXTURES_OK' not in r.stdout:
+ sys.exit(f'フィクスチャ投入に失敗:\n{r.stdout[-3000:]}\n{r.stderr[-2000:]}')
+ for line in r.stdout.splitlines():
+ if line.startswith('FIXTURES_OK') or line.strip().startswith('ERR'):
+ print(' ' + line.strip())
+
+ r = sh(['docker', 'cp', f'{container}:/tmp/_fixtures.json', a.out])
+ if r.returncode:
+ sys.exit(f'fixtures.json の取得に失敗: {r.stderr.strip()}')
+ data = json.load(open(a.out, encoding='utf-8'))
+ print(f"\n{a.out}")
+ print(f" users={len(data['users'])} records={len(data['records'])} "
+ f"index={data['index']} file={'あり' if data['file'] else 'なし'} "
+ f"token={'あり' if data['token'] else 'なし'} "
+ f"community={'あり' if data['community'] else 'なし'} "
+ f"group={'あり' if data['group'] else 'なし'} "
+ f"errors={len(data['errors'])}")
+ if data['errors']:
+ print(' ※ 失敗した投入があります。probe の測定範囲が狭まります。')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/api-inventory/scripts/merge.py b/tools/api-inventory/scripts/merge.py
new file mode 100644
index 0000000000..e611b39776
--- /dev/null
+++ b/tools/api-inventory/scripts/merge.py
@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+"""out/*.tsv をマージ・整形・採番して 1本の TSV にする。"""
+import glob, os, sys
+
+NCOL = 41
+HEADER = ['no','module','api_type','app','method','uri','path_params','query_params',
+ 'body_params','request_content_type','blueprint','endpoint','impl_func',
+ 'impl_file','impl_line','summary','response','response_content_type',
+ 'status_codes','exceptions','auth_required','auth_method','oauth_scope','roles',
+ 'auth_response_variance','restricted_content','data_op','data_target','data_store',
+ 'side_effects','cache_ratelimit','config_deps','api_version','deprecated','test_file',
+ 'last_commit','last_commit_date','last_commit_subject','release_tag',
+ 'category_tags','notes']
+assert len(HEADER) == NCOL
+
+def main(outdir, dst):
+ rows, seen, dup = [], set(), 0
+ for fp in sorted(glob.glob(os.path.join(outdir, '*.tsv'))):
+ for i, raw in enumerate(open(fp, encoding='utf-8'), 1):
+ raw = raw.rstrip('\n')
+ if not raw.strip():
+ continue
+ c = raw.split('\t')
+ if c[0].strip() in ('no', 'No', '#'): # 誤って書かれたヘッダ行を除去
+ continue
+ if len(c) < NCOL:
+ c += [''] * (NCOL - len(c))
+ elif len(c) > NCOL:
+ c = c[:NCOL - 1] + [' | '.join(c[NCOL - 1:])]
+ c = [x.replace('\t', ' ').replace('\r', '').strip() for x in c]
+ key = (c[5], c[4], c[13], c[14]) # uri, method, file, line
+ if key in seen:
+ dup += 1
+ continue
+ seen.add(key)
+ rows.append(c)
+ rows.sort(key=lambda c: (c[1], c[13], int(c[14]) if c[14].isdigit() else 0, c[5], c[4]))
+ for n, c in enumerate(rows, 1):
+ c[0] = str(n)
+ with open(dst, 'w', encoding='utf-8') as f:
+ f.write('\t'.join(HEADER) + '\n')
+ for c in rows:
+ f.write('\t'.join(c) + '\n')
+ print(f'merged rows={len(rows)} dropped_dup={dup} -> {dst}')
+
+if __name__ == '__main__':
+ main(sys.argv[1], sys.argv[2])
diff --git a/tools/api-inventory/scripts/paths.py b/tools/api-inventory/scripts/paths.py
new file mode 100644
index 0000000000..f20a840513
--- /dev/null
+++ b/tools/api-inventory/scripts/paths.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+"""台帳・スナップショット等の所在解決。
+
+WEKO3 リポジトリは **public** なので、所見(sec_*)や実証結果(dynamic_verified)を
+含むデータは一切置かない。データは秘密の場所で管理し、環境変数で指し示す。
+
+ export WEKO_API_INVENTORY_DIR=/path/to/weko-secret
+
+このディレクトリに置くもの:
+ weko3_api_list_full.tsv 台帳(57列・所見つき)
+ weko3_api_list.tsv 台帳(24列)
+ api_snapshot.json 経路のベースライン
+ reconcile_allow.json 実機に無い行の許可リスト
+"""
+import os
+import sys
+
+ENV = 'WEKO_API_INVENTORY_DIR'
+
+
+def data_dir(required=True):
+ """データディレクトリを返す。未設定なら理由を添えて中断する。"""
+ d = os.environ.get(ENV)
+ if d and os.path.isdir(d):
+ return d
+ if not required:
+ return None
+ sys.exit(
+ f'{ENV} が未設定、または存在しないディレクトリです。\n'
+ ' このリポジトリは public のため、台帳・スナップショットは同梱していません。\n'
+ f' 秘密の場所を指定してください: export {ENV}=/path/to/weko-secret\n'
+ ' 詳細は tools/api-inventory/ci/README.md')
+
+
+def data_path(name, required=True):
+ d = data_dir(required=required)
+ return os.path.join(d, name) if d else None
diff --git a/tools/api-inventory/scripts/prioritize.py b/tools/api-inventory/scripts/prioritize.py
new file mode 100644
index 0000000000..e4f8949dd9
--- /dev/null
+++ b/tools/api-inventory/scripts/prioritize.py
@@ -0,0 +1,362 @@
+# -*- coding: utf-8 -*-
+"""台帳に対応優先度(priority / priority_reason)を付与する。
+
+ python3 prioritize.py # $WEKO_API_INVENTORY_DIR の台帳を更新
+
+判定基準(上から順に評価し、最初に合致したものを採用する):
+
+ P0(至急) 無認証で **既存のファイル実体** を上書き/削除できる
+ (「データ破壊」を「既存の実データを不可逆に壊すこと」と定義する。
+ メタデータの更新や新規作成は破壊に含めない)
+ P0(最優先) 状態変更系(POST/PUT/DELETE/PATCH)なのに認証・権限チェックが一切ない、
+ または権限チェック機構はあるように見えるが実装上機能していない
+ P1(高) ログイン必須のみで所有者/ロール/スコープの限定がない状態変更系(IDOR疑い)、
+ ゲストトークン等のバイパス、ロールチェックが実質不問、または「不明」。
+ 加えて、認証が無い状態変更系でも **新規作成のみで既存データを
+ 壊さない** ものはここに下げる
+ 対象外 認証必須かつ admin-access 相当の権限チェックがあり、指摘が無いもの
+ P3(低) 意図的な公開設計(static配信・ヘルスチェック・robots.txt・OAI-PMH等)、
+ または deny_all 常時拒否
+ P2(中) 読み取り系(GET/HEAD)で認証・権限チェックが無い、または
+ ログイン必須のみでロール/所有者スコープなし
+ P4(低・実装済) 具体的な権限/所有者チェック機構が明記されており破綻が見えない
+
+テスト観点による引き上げ(上限 P2):
+ 正常値/異常値/境界値/例外処理 のチェックが確認できない行は、認可上の問題が
+ 無くても確認対象に上げる。ただし **引き上げは P2 まで** とする(認可の欠陥と
+ 同列には扱わない)。既に P0/P1/P2 の行は変更しない。
+
+評価順について: 「対象外」と P3 は P2 より先に評価する。admin-access で保護された
+管理画面や、意図的に公開している OAI-PMH を「認証なしの読み取り系」として
+P2 に落とすと、実際に見るべき行が埋もれるため。ただし **指摘(security_finding)や
+★実証がある行は「対象外」にしない**(保護されているように見えて破綻している行を
+除外してしまうため)。
+"""
+import argparse
+import json
+import os
+import re
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from paths import data_path # noqa: E402
+
+WRITE_METHODS = {'POST', 'PUT', 'DELETE', 'PATCH'}
+
+# 露出内容が「認証情報」とみなせる語
+CREDENTIAL_WORDS = (
+ '露出:秘密情報', '秘密:', 'client_secret', 'access_token', 'refresh_token',
+ 'password', 'cert_data', '資格情報', '外部トークン', 'APIキー', 'api_key',
+)
+# 露出内容が「非公開データの実体」とみなせる語
+NONPUBLIC_BODY_WORDS = (
+ '非公開業務データ', '非公開アイテム', 'ファイル実体', '非公開含む',
+ '全レコードJSON', '本文取得',
+)
+
+# 意図的な公開設計とみなす URI パターン
+PUBLIC_BY_DESIGN = [
+ (r'^/(api/)?ping$', 'ヘルスチェック'),
+ (r'robots\.txt', 'robots.txt'),
+ (r'/sitemap', 'サイトマップ配信'),
+ (r'^/(api/)?oai', 'OAI-PMH'),
+ (r'/resync/', 'ResourceSync'),
+ (r'^/(api/)?schema/', 'JSONスキーマ配信'),
+ (r'^/(api/)?lang', '言語切替'),
+ (r'/static/', '静的ファイル配信'),
+ (r'^/api/csl/styles$', 'CSLスタイル一覧'),
+ (r'^/$', 'トップページ'),
+]
+
+def load_allow():
+ """reconcile_allow.json を「実機に無い」ことの一次情報として読む。
+
+ 実測欄(dynamic_verified)の粒度はばらついており、同じ機能群でも
+ 「経路なし」と書かれた行と空欄の行が混在する。それを判定に使うと
+ 同一グループが 整理対象 と 対象外 に割れる。突き合わせで確認済みの
+ allow リストを正とする。
+ """
+ p = data_path('reconcile_allow.json', required=False)
+ if not p or not os.path.isfile(p):
+ return set(), set()
+ try:
+ a = json.load(open(p, encoding='utf-8'))
+ except Exception:
+ return set(), set()
+ return set(a.get('not_registered', {})), set(a.get('not_a_route', []))
+
+
+def norm_uri(u):
+ u = u.strip()
+ return u[:-1] if len(u) > 1 and u.endswith('/') else u
+
+
+# 「非利用」とみなす根拠。deprecated 列の記述と、実機 url_map に無いこと。
+UNUSED_WORDS = ('未使用', '非推奨', '実質未使用', '呼出元なし', '経路なし')
+
+# 具体的な権限/所有者チェック機構(P4 の根拠)
+CONCRETE_AUTHZ = [
+ 'page_permission_factory', 'check_created_id', 'roles_required',
+ 'permission.require', 'action-need', 'need_record_permission',
+ 'require_api_auth', 'require_oauth_scopes', 'admin-role-table',
+ 'file_permission_factory', 'check_index_access',
+]
+
+
+def field(cols, H, name):
+ i = H.get(name)
+ return cols[i] if i is not None and len(cols) > i else ''
+
+
+def classify(c, H):
+ """(priority, reason, cleanup) を返す。
+
+ cleanup は「非利用につき整理対象」の根拠。非利用の行は、認可上の問題が
+ P2 以下なら **削除すれば済む** ので `整理対象` に置き換える。P0/P1 の行は
+ 優先度を落とさず、理由に「削除が最短の対応」であることを添える。
+ """
+ method = field(c, H, 'method').upper()
+ uri = field(c, H, 'uri')
+ auth = field(c, H, 'auth') or (field(c, H, 'auth_required') + ' | ' +
+ field(c, H, 'auth_method'))
+ data_op = field(c, H, 'data_op') or field(c, H, 'data_op_detail')
+ finding = field(c, H, 'security_finding') or field(c, H, 'sec_pattern')
+ dyn = field(c, H, 'dynamic_verified')
+ cfg = field(c, H, 'config_deps')
+ # full.tsv は data_target(操作対象) と data_store(保存先) が別列、
+ # 24列版は両者を結合した data_store 1列。どちらでも拾えるよう連結する。
+ store = ' '.join(x for x in (field(c, H, 'data_target'),
+ field(c, H, 'data_store')) if x)
+
+ gap = field(c, H, 'test_gap')
+ dep = field(c, H, 'deprecated')
+ unused_src = ''
+ if dep and dep != '-' and any(w in dep for w in UNUSED_WORDS):
+ unused_src = dep
+ elif '経路なし' in dyn:
+ unused_src = '経路なし(実機 url_map に未登録)'
+
+ def bump(pri, why):
+ """テスト観点が確認できない行を P2 まで引き上げる。
+
+ 認可上の問題が無くても「4観点のチェックが確認できない」なら確認対象に
+ 上げる。ただし認可の欠陥と同列にはしないため **上限は P2**。
+ """
+ if not gap or gap == '-':
+ return pri, why
+ if gap == '特定不能':
+ return 'P2(中)', f'{why} / 対応するテスト関数を特定できずテスト観点を確認できない'
+ if gap.count(',') == 3:
+ return 'P2(中)', f'{why} / テストの4観点(正常値・異常値・境界値・例外処理)が全て確認できない'
+ return pri, f'{why} / テスト観点の欠落: {gap}'
+
+ methods = {m.strip() for m in method.split(',') if m.strip()}
+ auth_req = auth.split('|')[0].strip()
+
+ is_write_method = bool(methods & WRITE_METHODS)
+ is_destructive = ('削除' in data_op)
+ is_mutating = any(k in data_op for k in ('作成', '更新', '削除'))
+ no_auth = auth_req in ('不要', '任意(匿名可)')
+ unauth_reach = ('未認証で到達' in dyn) or ('未認証で' in dyn and '★' in dyn)
+ proven = '★' in dyn
+ broken_authz = '実効せず' in finding
+ login_only = ('ログインのみで到達' in dyn) or ('低権限ログインで到達' in dyn)
+ scope_missing = any(k in finding for k in (
+ '所有者チェック欠落', '権限過小', '兄弟と不揃い', '読み書き非対称'))
+ guest_bypass = 'session+guest' in auth or 'guest_token' in finding
+ unknown = (dyn.strip() in ('', '-'))
+ has_finding = finding.strip() not in ('', '-')
+
+ # --- P0(至急) 無認証で既存のファイル実体を壊せる ---
+ # 「データ破壊」= 既存の実データを不可逆に壊すこと。メタデータの更新や
+ # 新規作成は含めない。この定義で 926 行中 no.503(POST /records/replace_file)
+ # だけが該当する。
+ destroys_real_data = ('ファイル実体' in store) and ('更新' in data_op or '削除' in data_op)
+ if (no_auth or unauth_reach) and destroys_real_data:
+ return 'P0(至急)', f'無認証で既存のファイル実体を上書き/削除できる (data_op={data_op})'
+
+ # --- P0(最優先) 状態変更系で認証・権限が無い/機能していない ---
+ # ただし新規作成しかせず既存データを壊さないものは P1 に下げる(下の P1 で拾う)。
+ creates_only = ('作成' in data_op) and not ('更新' in data_op or '削除' in data_op)
+ if is_write_method and (no_auth or unauth_reach) and not creates_only:
+ why = '認証チェックが無い' if no_auth else '認証はあるが未認証で到達(実測)'
+ return 'P0(最優先)', f'状態変更系({method})で{why}'
+ if is_write_method and broken_authz and not creates_only:
+ return 'P0(最優先)', f'状態変更系({method})だが権限チェックが実装上機能していない'
+
+ # --- P1(高) ---
+ # 認証が無い状態変更系でも、新規作成のみで既存データを壊さないものはここ。
+ if is_write_method and (no_auth or unauth_reach or broken_authz) and creates_only:
+ return 'P1(高)', f'状態変更系({method})で認証チェックが無いが、新規作成のみで既存データは壊さない'
+ if is_write_method and guest_bypass:
+ return 'P1(高)', f'状態変更系({method})にゲストトークンのバイパス経路がある'
+ if is_write_method and (login_only or scope_missing):
+ why = 'ログイン必須のみで所有者/ロール限定なし(IDOR疑い)' if login_only \
+ else finding.split(';')[0].strip()[:60]
+ return 'P1(高)', f'状態変更系({method}): {why}'
+ if is_write_method and unknown:
+ return 'P1(高)', f'状態変更系({method})だが到達可否が未測定(不明)'
+
+ # --- 露出内容による引き上げ(参照系でも P1) ---
+ # 「読み取り系だから情報漏洩リスクは限定的」は、露出するものが認証情報や
+ # 非公開データの実体である場合には成り立たない。認可が緩い行に限って P1 に上げる。
+ # 「管理者に集約」のように適切に保護されている行は対象にしない。
+ # restricted_content / access_variance は「何が制限されているか」の説明であり、
+ # 適切に絞られている旨の記述にも「非公開」が出てくる。露出の根拠には使わない
+ # (例: no.575 GET / は「ブラウジング権限のあるインデックスのみ」と書かれており
+ # 指摘も無いのに、これを拾うと P1 に誤って上がる)。
+ exposure = ' '.join((finding, field(c, H, 'sec_exposed'),
+ field(c, H, 'sec_detail')))
+ # 指摘または★実証がある行に限る。露出の記述があるだけでは上げない。
+ weak_access = (no_auth or unauth_reach or login_only or scope_missing) \
+ and (has_finding or proven)
+ if (not is_write_method) and weak_access:
+ cred = [w for w in CREDENTIAL_WORDS if w in exposure]
+ body = [w for w in NONPUBLIC_BODY_WORDS if w in exposure or w in store]
+ if cred:
+ return 'P1(高)', f'参照系だが露出内容が認証情報({cred[0]})で、認可が緩い'
+ if body:
+ return 'P1(高)', f'参照系だが露出内容が非公開データの実体({body[0]})で、認可が緩い'
+
+ # --- 対象外: admin-access 相当で保護され、指摘も実証も無い ---
+ if (not has_finding) and (not proven) and (
+ 'admin-role-table' in auth or 'admin-access' in auth
+ or auth_req == '要(管理)'):
+ return bump('対象外', '認証必須+admin-access 相当の権限チェックあり、指摘なし')
+
+ # --- P3(低) 意図的な公開設計 / deny_all ---
+ for pat, label in PUBLIC_BY_DESIGN:
+ if re.search(pat, uri):
+ return bump('P3(低)', f'意図的な公開設計({label})')
+ if 'deny_all' in auth or 'deny_all' in cfg:
+ return bump('P3(低)', 'deny_all で常時拒否(逆方向の問題)')
+
+ # --- P2(中) 読み取り系 ---
+ if not is_write_method:
+ if no_auth or unauth_reach:
+ return 'P2(中)', f'読み取り系({method})で認証・権限チェックが無い'
+ if login_only or scope_missing:
+ why = 'ログイン必須のみでロール/所有者スコープなし' if login_only \
+ else finding.split(';')[0].strip()[:60]
+ return 'P2(中)', f'読み取り系({method}): {why}'
+ if unknown and has_finding:
+ return 'P2(中)', f'読み取り系({method})に指摘があるが到達可否は未測定'
+
+ # --- P4(低・実装済) ---
+ hit = [k for k in CONCRETE_AUTHZ if k in auth]
+ if hit:
+ return bump('P4(低・実装済)', f'具体的な権限チェック機構あり({", ".join(hit[:3])})')
+ if auth_req.startswith('要'):
+ return bump('P4(低・実装済)', f'認証必須({auth_req})で破綻は見えない')
+ return 'P2(中)', '分類条件に合致せず。手動確認が必要'
+
+
+def decide(c, H, allow=(frozenset(), frozenset())):
+ """classify の結果に「実機に無い」「非利用」の判定を重ねる。"""
+ pri, why = classify(c, H)
+ not_registered, not_a_route = allow
+
+ # --- 実機に無い(環境依存で無効) ---
+ # 削除候補ではない。別の設定・別サイトでは有効になるため台帳に残す。
+ uris = [norm_uri(x) for x in field(c, H, 'uri').split(';') if norm_uri(x)]
+ hit = [u for u in uris if u in not_registered]
+ if hit or field(c, H, 'no') in not_a_route:
+ src = ('起動後に動的登録されるためURIが静的に定まらない'
+ if not hit else 'この環境では未登録(プラグイン未導入・config で無効等)')
+ if pri in ('P0(至急)', 'P0(最優先)', 'P1(高)'):
+ return pri, f'{why} / 実機に無い({src})が、有効な環境では成立しうる', '-'
+ return '環境依存', f'実機に無い: {src} / 認可上の判定は {pri}', '-'
+
+ dep = field(c, H, 'deprecated')
+ dyn = field(c, H, 'dynamic_verified')
+ src = ''
+ if dep and dep != '-' and any(w in dep for w in UNUSED_WORDS):
+ src = dep
+ elif '経路なし' in dyn:
+ src = '経路なし(実機 url_map に未登録)'
+ if not src:
+ return pri, why, '-'
+ if pri in ('P0(至急)', 'P0(最優先)', 'P1(高)'):
+ return pri, f'{why} / 非利用({src[:40]})のため削除が最短の対応', src
+ return '整理対象', f'非利用({src[:60]}) / 認可上の判定は {pri}', src
+
+
+TAIL = ['priority', 'priority_reason', 'test_normal', 'test_abnormal',
+ 'test_boundary', 'test_exception', 'test_gap', 'cleanup']
+
+
+def apply_to(path, out=None):
+ with open(path, encoding='utf-8') as f:
+ lines = f.read().rstrip('\n').split('\n')
+ hdr = lines[0].split('\t')
+ H0 = {n: i for i, n in enumerate(hdr)}
+ rows = [r.split('\t') for r in lines[1:]]
+
+ # 既存の派生列を一旦外し、本体列だけにする
+ base_idx = [i for i, n in enumerate(hdr) if n not in ('priority', 'priority_reason', 'cleanup')]
+ base_hdr = [hdr[i] for i in base_idx]
+
+ allow = load_allow()
+ out_rows = []
+ counts = {}
+ cleanup_n = 0
+ for c in rows:
+ c = c + [''] * (len(hdr) - len(c))
+ pri, why, cl = decide(c, H0, allow)
+ counts[pri] = counts.get(pri, 0) + 1
+ if cl != '-':
+ cleanup_n += 1
+ base = [c[i] for i in base_idx]
+ out_rows.append((base, pri, why.replace('\t', ' '), cl.replace('\t', ' ')))
+
+ # 末尾の並びを実行順に依存させない(canonical order に揃える)
+ tail_present = [n for n in TAIL if n in base_hdr or n in ('priority', 'priority_reason', 'cleanup')]
+ body_hdr = [n for n in base_hdr if n not in TAIL]
+ tail_hdr = ['priority', 'priority_reason'] + \
+ [n for n in ('test_normal', 'test_abnormal', 'test_boundary',
+ 'test_exception', 'test_gap') if n in base_hdr] + ['cleanup']
+ bi = {n: i for i, n in enumerate(base_hdr)}
+ final = ['\t'.join(body_hdr + tail_hdr)]
+ for base, pri, why, cl in out_rows:
+ vals = {**{n: base[bi[n]] for n in body_hdr},
+ 'priority': pri, 'priority_reason': why, 'cleanup': cl}
+ for n in ('test_normal', 'test_abnormal', 'test_boundary',
+ 'test_exception', 'test_gap'):
+ if n in bi:
+ vals[n] = base[bi[n]]
+ final.append('\t'.join(vals[n] for n in body_hdr + tail_hdr))
+ with open(out or path, 'w', encoding='utf-8') as f:
+ f.write('\n'.join(final) + '\n')
+ counts['__cleanup__'] = cleanup_n
+ return counts
+
+
+ORDER = ['P0(至急)', 'P0(最優先)', 'P1(高)', 'P2(中)', 'P3(低)',
+ 'P4(低・実装済)', '整理対象', '環境依存', '対象外']
+
+
+def main():
+ p = argparse.ArgumentParser(description='台帳に対応優先度を付与する')
+ p.add_argument('--full', default=None, help='既定: $WEKO_API_INVENTORY_DIR/weko3_api_list_full.tsv')
+ p.add_argument('--dry-run', action='store_true')
+ a = p.parse_args()
+ full = a.full or data_path('weko3_api_list_full.tsv')
+
+ import tempfile
+ tmp = tempfile.mktemp(suffix='.tsv') if a.dry_run else None
+ counts = apply_to(full, out=tmp)
+ cleanup_n = counts.pop('__cleanup__', 0)
+ total = sum(counts.values())
+ print(f'{"(dry-run) " if a.dry_run else ""}{full}: {total} 行に priority を付与')
+ for k in ORDER:
+ if counts.get(k):
+ print(f' {k:<14} {counts[k]:>4}')
+ for k in sorted(set(counts) - set(ORDER)):
+ print(f' {k:<14} {counts[k]:>4} ← 未定義の区分')
+ print(f' (うち非利用と判定: {cleanup_n} 行)')
+ if tmp:
+ os.unlink(tmp)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/api-inventory/scripts/probe.py b/tools/api-inventory/scripts/probe.py
new file mode 100644
index 0000000000..11836d0482
--- /dev/null
+++ b/tools/api-inventory/scripts/probe.py
@@ -0,0 +1,63 @@
+# [参考実装] 動的検証用。セッション固有の絶対パス(scratchpad/ck等)を含むため、
+# 再利用時は BASE/HOST/Cookie保存先/probe対象TSVパスを環境に合わせて修正すること。
+# 手順は tools/api-inventory/README.md の Phase 3 を参照。
+# -*- coding: utf-8 -*-
+"""全フラグ付きエンドポイントを実機で叩き、未認証+各ロールの実測結果を得る。"""
+import re, subprocess, sys, json
+ROOTF="/home/mhaya/wekov2/"
+CK=ROOTF and "/tmp/claude-1000/-home-mhaya-wekov2/a8119b60-023e-4882-84ac-a0edcfb5627e/scratchpad/api/ck/"
+BASE="https://localhost:8443"; HOST="weko3.example.org"
+
+def load(p): return [l.rstrip("\n").split("\t") for l in open(p,encoding="utf-8") if l.rstrip("\n")]
+rows=load(ROOTF+"weko3_api_list.tsv"); hd=rows[0]; data=rows[1:]
+
+# プレースホルダ→実値
+def resolve(uri):
+ u=uri
+ u=re.sub(r"","v1",u)
+ u=re.sub(r"","6e74ad33-e886-4c27-8d10-45a160fae30c:c7718302-39fa-472b-92aa-81e61a9d9f4d:secret.png",u)
+ u=re.sub(r"<[^>]*api_code>","crf",u)
+ u=re.sub(r"<[^>]*version[^>]*>","v1",u)
+ u=re.sub(r"<[^>]*index_id>","100100",u)
+ u=re.sub(r"<[^>]*journal_id>","1",u)
+ u=re.sub(r"","top_page_access",u); u=re.sub(r"","2026",u); u=re.sub(r"","8",u)
+ u=re.sub(r"<[^>]*(file_name|filename|key)>","secret.png",u)
+ u=re.sub(r"]+>","secret.png",u)
+ u=re.sub(r"<[^>]*(pid_value|recid|identifier)>","1001",u)
+ u=re.sub(r"<[^>]*activity_id>","A-00000000-0000",u)
+ u=re.sub(r"<[^>]*community_id>","comm1",u)
+ u=re.sub(r"<[^>]*(id|Id)>","1",u)
+ u=re.sub(r"<[^>]+>","1",u) # 残り
+ return u
+
+def curl(method, url, cookie):
+ args=["curl","-sk","-o","/dev/null","-w","%{http_code}","--max-time","12","-X",method.split(",")[0],
+ "-H","Host: "+HOST]
+ if cookie: args+=["-b",cookie]
+ m=method.split(",")[0]
+ if m in ("POST","PUT","PATCH"):
+ args+=["-H","Content-Type: application/json","-d","{}"]
+ args.append(BASE+url)
+ try:
+ return subprocess.run(args,capture_output=True,text=True,timeout=20).stdout.strip() or "000"
+ except Exception:
+ return "ERR"
+
+IDN={"anon":None,"general":CK+"general.txt","comadmin":CK+"comadmin.txt","repoadmin":CK+"repoadmin.txt"}
+
+results={} # idx -> {ident:status}
+flagged=[i for i,c in enumerate(data) if len(c)>=42 and c[41]!="-"]
+print("flagged endpoints:",len(flagged),file=sys.stderr)
+for n,i in enumerate(flagged):
+ c=data[i]; method=c[4] or "GET"; url=resolve(c[5])
+ admin = ("/admin" in c[5]) or ("/api/admin" in c[5])
+ idents=["anon","general"] + (["comadmin","repoadmin"] if admin else [])
+ # 破壊的メソッドは general までに留め、管理系のみ repoadmin も(環境は使い捨て)
+ r={}
+ for ident in idents:
+ r[ident]=curl(method,url,IDN[ident])
+ results[i]=r
+ if n%25==0: print(f" {n}/{len(flagged)}",file=sys.stderr)
+
+json.dump({str(k):v for k,v in results.items()}, open(sys.argv[1],"w"))
+print("done",len(results),file=sys.stderr)
diff --git a/tools/api-inventory/scripts/probe_ci.py b/tools/api-inventory/scripts/probe_ci.py
new file mode 100644
index 0000000000..7fbd009625
--- /dev/null
+++ b/tools/api-inventory/scripts/probe_ci.py
@@ -0,0 +1,307 @@
+# -*- coding: utf-8 -*-
+"""Phase 7-b: フィクスチャ駆動の到達可否測定(CI向け)。
+
+ python3 probe_ci.py --only rerun_nos.txt --out probe.json --gate
+
+`probe.py` は参考実装でセッション固有のUUID・パスがハードコードされている。
+本スクリプトは `fixtures.py` が出力した fixtures.json からプレースホルダを解決するため、
+まっさらな CI 環境でも動く。
+
+測定対象は `--only` で渡した `no` に限定する(全926行を毎PR測るのは時間がかかりすぎる)。
+CI では changed_rows.py の出力と diff_snapshot の ADDED/AUTH_CHANGED の和集合を渡す。
+
+安全装置: GET/HEAD 以外は既定でスキップする。書き込み系まで測るには --allow-writes を
+明示すること(CI のコンテナは install.sh が毎回作り直す使い捨てなので許可してよい)。
+"""
+import argparse
+import json
+import os
+import subprocess
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from paths import data_path # noqa: E402
+import tempfile
+
+IDENTITIES = [
+ ('anon', None),
+ ('general', 'user@example.org'),
+ ('contributor', 'contributor@example.org'),
+ ('comadmin', 'comadmin@example.org'),
+ ('repoadmin', 'repoadmin@example.org'),
+ ('sysadmin', 'wekosoftware@nii.ac.jp'),
+]
+
+SAFE_METHODS = ('GET', 'HEAD')
+
+
+def curl(args):
+ return subprocess.run(['curl', '-sk', '--max-time', '15'] + args,
+ capture_output=True, text=True)
+
+
+class Session:
+ """identity ごとの Cookie を持つ。測定直前にログインし直して失効を避ける。"""
+
+ def __init__(self, base, host, email, password, workdir):
+ self.base, self.host, self.email = base, host, email
+ self.jar = os.path.join(workdir, f'ck_{email or "anon"}')
+ self.ok = True
+ if email:
+ r = curl(['-c', self.jar, '-o', '/dev/null', '-w', '%{http_code}',
+ '-H', f'Host: {host}', '-X', 'POST',
+ '-H', 'Content-Type: application/json',
+ '-d', json.dumps({'email': email, 'password': password}),
+ f'{base}/api/v1/login'])
+ self.ok = r.stdout.strip() == '200'
+
+ def request(self, method, path, body_file):
+ args = ['-o', body_file, '-w', '%{http_code}\t%{redirect_url}',
+ '-H', f'Host: {self.host}', '-X', method]
+ if self.email:
+ args += ['-b', self.jar]
+ if method not in SAFE_METHODS:
+ args += ['-H', 'Content-Type: application/json', '-d', '{}']
+ args.append(f'{self.base}{path}')
+ out = curl(args).stdout.strip().split('\t')
+ return out[0], (out[1] if len(out) > 1 else '')
+
+
+def classify(code, body_path, redirect=''):
+ """到達(認可を通過してハンドラに入った) / 遮断 / 判定不能 を分ける。
+
+ 500 は原則「認可通過後のクラッシュ=到達」だが、APIアプリの login_required は
+ url_for('security.login') の BuildError で 500 になる(= 遮断)。本文で切り分ける。
+ """
+ try:
+ body = open(body_path, encoding='utf-8', errors='replace').read(4000)
+ except Exception:
+ body = ''
+ if code in ('401', '403'):
+ return '遮断'
+ if code in ('301', '302', '303', '307', '308'):
+ # ログイン画面への転送は遮断。それ以外は処理が完了した転送(到達)。
+ # no.480 /record//publish は未認証 302 で publish_status が実際に
+ # 書き換わることを実証済みで、302 を一律「遮断」とすると取りこぼす。
+ low = (redirect or '').lower()
+ if 'login' in low or 'signin' in low or 'sso' in low:
+ return '遮断'
+ return '到達'
+ if code == '500':
+ return '遮断' if ('security.login' in body or 'BuildError' in body) else '到達'
+ if code == '404':
+ return '判定不能' # hidden=True の権限NG か、対象が無いだけか区別できない
+ if code == '000':
+ return '判定不能' # タイムアウト等
+ if code.startswith('2') or code in ('400', '405', '415'):
+ return '到達'
+ return '判定不能'
+
+
+def build_resolver(fx):
+ """URI のプレースホルダをフィクスチャの実値に置き換える表を作る。"""
+ priv = fx['records'].get('private', {})
+ other = fx['records'].get('other_owner', {})
+ f = fx.get('file') or {}
+ table = [
+ (r'', '__VERSION__'),
+ (r'<[^>]*\bversion\b[^>]*>', '__VERSION__'),
+ (r'<[^>]*uuid[^>]*>', f.get('uuid_triplet', '')),
+ (r'<[^>]*bucket_id[^>]*>', f.get('bucket', '')),
+ (r']+>', 'secret.png'),
+ (r'<[^>]*(key|filename|file_name)[^>]*>', 'secret.png'),
+ (r'<[^>]*(pid_value|recid|pid\()[^>]*>', str(priv.get('recid', ''))),
+ (r'<[^>]*index_id[^>]*>', str(fx.get('index', ''))),
+ (r'<[^>]*community_id[^>]*>', (fx.get('community') or {}).get('id', '')),
+ (r'<[^>]*group_id[^>]*>', str((fx.get('group') or {}).get('id', ''))),
+ (r'<[^>]*user_id[^>]*>', str(other.get('owner', ''))),
+ (r'<[^>]*api_code[^>]*>', 'crf'),
+ # IIIF Image API のパラメータ(no.34)
+ (r'<[^>]*region[^>]*>', 'full'),
+ (r'<[^>]*size[^>]*>', 'full'),
+ (r'<[^>]*rotation[^>]*>', '0'),
+ (r'<[^>]*quality[^>]*>', 'default'),
+ (r'<[^>]*image_format[^>]*>', 'png'),
+ # ワークフロー(activity)はフィクスチャに無いので解決しない → skip 扱い
+ ]
+ return table
+
+
+PID_PAT = r'<[^>]*(pid_value|recid|pid\()[^>]*>'
+
+
+def resolve_variants(uri, table, fx):
+ """(ラベル, 解決後パス) の一覧を返す。
+
+ アイテムIDのプレースホルダは **公開/非公開の両方**で測る。どちらを入れるかで
+ 結論が変わるため。例: no.480 /record//publish は非公開アイテムだと
+ 未認証でログインへ転送されるが、公開アイテムだと未認証で publish_status が
+ 書き換わることを実証済み。片方だけでは取りこぼす。
+ """
+ import re
+ ver = 'v2' if '/iiif/' in uri else 'v1' # は文脈依存
+ base = uri
+ for pat, val in table:
+ if val == '__VERSION__':
+ val = ver
+ if not val:
+ continue
+ base = re.sub(pat, val, base)
+
+ if not re.search(PID_PAT, uri):
+ return [('-', base)]
+ out = []
+ for label in ('public', 'private'):
+ rec = fx['records'].get(label)
+ if not rec:
+ continue
+ out.append((label, re.sub(PID_PAT, str(rec['recid']), uri if False else base)))
+ # base は既に private で置換済みなので、公開版は uri から作り直す
+ fixed = []
+ for label, _ in out:
+ rec = fx['records'][label]
+ u = re.sub(PID_PAT, str(rec['recid']), uri)
+ for pat, val in table:
+ if val == '__VERSION__':
+ val = ver
+ if not val or pat == PID_PAT:
+ continue
+ u = re.sub(pat, val, u)
+ fixed.append((label, u))
+ return fixed or [('-', base)]
+
+
+def load_tsv(path):
+ rows = {}
+ with open(path, encoding='utf-8') as f:
+ hdr = f.readline().rstrip('\n').split('\t')
+ H = {n: i for i, n in enumerate(hdr)}
+ for line in f:
+ c = line.rstrip('\n').split('\t')
+ if len(c) > H['uri']:
+ rows[c[H['no']]] = c
+ return rows, H
+
+
+def main():
+ here = os.path.dirname(os.path.abspath(__file__))
+ base_dir = os.path.dirname(here)
+ p = argparse.ArgumentParser(description='フィクスチャ駆動の到達可否測定')
+ p.add_argument('--fixtures', default='fixtures.json')
+ p.add_argument('--tsv', default=None, help='既定: $WEKO_API_INVENTORY_DIR/weko3_api_list_full.tsv')
+ p.add_argument('--only', help='測定対象の no を1行1件で並べたファイル')
+ p.add_argument('--nos', help='測定対象の no をカンマ区切りで直接指定')
+ p.add_argument('--base', default='https://localhost:8443')
+ p.add_argument('--host', default='weko3.example.org')
+ p.add_argument('--allow-writes', action='store_true',
+ help='GET/HEAD 以外も測る(使い捨て環境でのみ指定すること)')
+ p.add_argument('--out', default='probe.json')
+ p.add_argument('--gate', action='store_true', help='G8/G9 に該当があれば exit 1')
+ p.add_argument('--summary-only', action='store_true',
+ help='件数のみ出力する。public リポジトリの CI ログ/artifact/PRコメントは'
+ '誰でも読めるため、URI や測定結果の明細を出さない')
+ a = p.parse_args()
+ a.tsv = a.tsv or data_path('weko3_api_list_full.tsv')
+
+ fx = json.load(open(a.fixtures, encoding='utf-8'))
+ rows, H = load_tsv(a.tsv)
+
+ targets = []
+ if a.only and os.path.isfile(a.only):
+ targets += [l.strip() for l in open(a.only, encoding='utf-8') if l.strip()]
+ if a.nos:
+ targets += [x.strip() for x in a.nos.split(',') if x.strip()]
+ targets = [t for t in dict.fromkeys(targets) if t in rows]
+ if not targets:
+ print('測定対象がありません(--only / --nos)。')
+ json.dump({'results': [], 'skipped': 'no targets'},
+ open(a.out, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
+ return
+
+ work = tempfile.mkdtemp(prefix='api-probe-')
+ sessions = {}
+ for name, email in IDENTITIES:
+ s = Session(a.base, a.host, email, fx['password'], work)
+ if email and not s.ok:
+ print(f' 警告: {name} ({email}) のログインに失敗。この identity は測れません。')
+ sessions[name] = s
+
+ table = build_resolver(fx)
+ results = []
+ for no in targets:
+ c = rows[no]
+ uri, methods = c[H['uri']], c[H['method']]
+ for label, path in resolve_variants(uri.split(';')[0], table, fx):
+ if '<' in path:
+ results.append({'no': no, 'uri': uri, 'status': 'skip',
+ 'reason': '未解決プレースホルダ: ' + path})
+ continue
+ for method in [m for m in methods.replace(' ', '').split(',') if m]:
+ if method not in SAFE_METHODS and not a.allow_writes:
+ results.append({'no': no, 'uri': uri, 'method': method,
+ 'status': 'skip',
+ 'reason': '書き込み系(--allow-writes 未指定)'})
+ continue
+ obs = {}
+ for name, _ in IDENTITIES:
+ sess = sessions[name]
+ if sess.email and not sess.ok:
+ continue
+ bf = os.path.join(work, 'body')
+ code, redirect = sess.request(method, path, bf)
+ obs[name] = {'code': code,
+ 'verdict': classify(code, bf, redirect),
+ 'redirect': redirect or None}
+ results.append({'no': no, 'uri': uri, 'method': method,
+ 'target': label, 'resolved': path,
+ 'status': 'measured',
+ 'data_op': c[H['data_op']], 'observed': obs,
+ 'recorded': c[H['dynamic_verified']]})
+
+ # --- ゲート ---
+ g8, g9 = [], []
+ for r in results:
+ if r.get('status') != 'measured':
+ continue
+ anon = (r['observed'].get('anon') or {}).get('verdict')
+ if anon == '到達':
+ if any(k in r['data_op'] for k in ('作成', '更新', '削除')):
+ g8.append(r)
+ if '遮断' in (r['recorded'] or '') and '到達' not in (r['recorded'] or ''):
+ g9.append(r)
+
+ out = {'targets': len(targets), 'results': results,
+ 'gates': {'G8_unauth_write': g8, 'G9_regression': g9}}
+ json.dump(out, open(a.out, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
+
+ measured = [r for r in results if r.get('status') == 'measured']
+ skipped = [r for r in results if r.get('status') == 'skip']
+ print(f"\n測定 {len(measured)} / スキップ {len(skipped)} (対象 no {len(targets)}件)")
+ if a.summary_only:
+ print(' (件数のみ。明細は秘密側の完全版レポートを参照)')
+ print(f"\n[G8] 未認証で到達する書き込み系: {len(g8)}件")
+ print(f"[G9] 台帳では遮断だが実測で到達(回帰): {len(g9)}件")
+ if a.gate and (g8 or g9):
+ sys.exit(1)
+ return
+ for r in measured:
+ obs = ' '.join(f"{k}={v['code']}({v['verdict']})" for k, v in r['observed'].items())
+ tgt = f"[{r.get('target', '-')}]"
+ print(f" no={r['no']:<5} {r['method']:<7} {tgt:<10} {r['uri'][:44]:<44} {obs}")
+ for r in skipped:
+ print(f" no={r['no']:<5} skip: {r['reason']}")
+ if g8:
+ print(f"\n[FAIL] G8 未認証で到達する書き込み系: {len(g8)}件")
+ for r in g8:
+ print(f" no={r['no']} {r['method']} [{r.get('target','-')}] "
+ f"{r['uri'][:60]} data_op={r['data_op']}")
+ if g9:
+ print(f"\n[FAIL] G9 台帳では遮断だが実測で到達(回帰): {len(g9)}件")
+ for r in g9:
+ print(f" no={r['no']} {r['method']} [{r.get('target','-')}] {r['uri'][:60]}")
+ if a.gate and (g8 or g9):
+ sys.exit(1)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/api-inventory/scripts/reconcile.py b/tools/api-inventory/scripts/reconcile.py
new file mode 100644
index 0000000000..c2092f7f1c
--- /dev/null
+++ b/tools/api-inventory/scripts/reconcile.py
@@ -0,0 +1,227 @@
+# -*- coding: utf-8 -*-
+"""スナップショット(実機url_map) ↔ インベントリTSV の突き合わせ。
+
+ python3 reconcile.py --gate
+
+検出するもの:
+ A. インベントリ未収載の経路 … 実機にあるが台帳に無い(=抽出漏れ)
+ B. 実機に無いインベントリ行 … 台帳にあるが実機url_mapに無い(未登録/条件付き)
+ C. メソッド不一致 … 同一URIでHTTPメソッドが食い違う
+ D. app列の不一致 … UI/API どちらに登録されているかの記載誤り
+
+B は「プラグイン未登録」「config で無効」等の正当な理由があるものを
+`reconcile_allow.json` に登録して既知として扱う(理由を必ず書く)。
+
+URI の正規化規則:
+ - スナップショット: APIアプリのルールには `/api` を前置する
+ (APIアプリは DispatcherMiddleware で /api にマウントされるため url_map 側には出ない)
+ - インベントリ: uri セルの `;` 区切りを展開。`app=両方` の行は `/api` 側も展開
+ - 末尾スラッシュは除去して比較
+"""
+import argparse
+import collections
+import json
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from paths import data_path # noqa: E402
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+BASE = os.path.dirname(HERE)
+
+
+def norm(u):
+ u = u.strip()
+ return u[:-1] if len(u) > 1 and u.endswith('/') else u
+
+
+def load_snapshot(path):
+ """uri -> {'methods': set, 'keys': [snapshot key], 'apps': set}"""
+ snap = json.load(open(path, encoding='utf-8'))
+ out = {}
+ for key, v in snap['endpoints'].items():
+ prefix = '/api' if v['app'] == 'api' else ''
+ for rt in v['routes']:
+ u = norm(prefix + rt['rule'])
+ e = out.setdefault(u, {'methods': set(), 'keys': [], 'apps': set(),
+ 'provider': v.get('provider'), 'attrs': v.get('attrs')})
+ # HEAD/OPTIONS は werkzeug が GET に自動付与するため比較対象外(ダンプ側で除外済み)
+ e['methods'].update(rt['methods'])
+ e['keys'].append(key)
+ e['apps'].add(v['app'])
+ return snap, out
+
+
+def load_inventory(path):
+ """uri -> {'methods': set, 'nos': [no], 'app_col': set}"""
+ out = {}
+ rows = {}
+ with open(path, encoding='utf-8') as f:
+ hdr = f.readline().rstrip('\n').split('\t')
+ i_no, i_app = hdr.index('no'), hdr.index('app')
+ i_m, i_u = hdr.index('method'), hdr.index('uri')
+ for line in f:
+ c = line.rstrip('\n').split('\t')
+ if len(c) <= i_u:
+ continue
+ rows[c[i_no]] = c
+ # HEAD/OPTIONS は比較対象外(werkzeug が GET に自動付与するため)
+ methods = {m for m in c[i_m].replace(' ', '').split(',') if m
+ and m not in ('HEAD', 'OPTIONS')}
+ uris = [norm(u) for u in c[i_u].split(';') if norm(u)]
+ expanded = list(uris)
+ if c[i_app] == '両方':
+ expanded += [norm('/api' + u) for u in uris]
+ for u in expanded:
+ e = out.setdefault(u, {'methods': set(), 'nos': [], 'app_col': set()})
+ e['methods'].update(methods)
+ e['nos'].append(c[i_no])
+ e['app_col'].add(c[i_app])
+ return out, rows, hdr
+
+
+def app_expected(apps):
+ """スナップショット側の app 集合 -> インベントリの app 列の期待値"""
+ if apps == {'ui'}:
+ return 'UIアプリ'
+ if apps == {'api'}:
+ return 'APIアプリ(/api)'
+ return '両方'
+
+
+def main():
+ p = argparse.ArgumentParser(description='スナップショットとインベントリを突き合わせる')
+ p.add_argument('--snapshot', default=None, help='既定: $WEKO_API_INVENTORY_DIR/api_snapshot.json')
+ p.add_argument('--tsv', default=None, help='既定: $WEKO_API_INVENTORY_DIR/weko3_api_list_full.tsv')
+ p.add_argument('--allow', default=None, help='既定: $WEKO_API_INVENTORY_DIR/reconcile_allow.json')
+ p.add_argument('--out', help='Markdown 出力先')
+ p.add_argument('--gate', action='store_true', help='未説明の差分があれば exit 1')
+ p.add_argument('--summary-only', action='store_true',
+ help='件数のみ出力する。public リポジトリの CI ログ/artifact/PRコメントは'
+ '誰でも読めるため、URI や endpoint 名を出さない')
+ a = p.parse_args()
+ a.snapshot = a.snapshot or data_path('api_snapshot.json')
+ a.tsv = a.tsv or data_path('weko3_api_list_full.tsv')
+ a.allow = a.allow or data_path('reconcile_allow.json')
+
+ snap, S = load_snapshot(a.snapshot)
+ I, rows, _hdr = load_inventory(a.tsv)
+ allow = {}
+ if os.path.isfile(a.allow):
+ allow = json.load(open(a.allow, encoding='utf-8'))
+ allow_uris = allow.get('not_registered', {})
+ allow_notreal = set(allow.get('not_a_route', []))
+
+ # A. インベントリ未収載
+ missing = []
+ for u in sorted(set(S) - set(I)):
+ e = S[u]
+ missing.append({'uri': u, 'methods': sorted(e['methods']),
+ 'key': e['keys'][0], 'provider': e.get('provider')})
+
+ # B. 実機に無いインベントリ行
+ phantom, phantom_known = [], []
+ for u in sorted(set(I) - set(S)):
+ item = {'uri': u, 'nos': I[u]['nos'], 'methods': sorted(I[u]['methods'])}
+ nos = set(I[u]['nos'])
+ if u in allow_uris or nos & allow_notreal:
+ item['reason'] = allow_uris.get(u) or '(URIではない行)'
+ phantom_known.append(item)
+ else:
+ phantom.append(item)
+
+ # C. メソッド不一致
+ method_diff = []
+ for u in sorted(set(S) & set(I)):
+ sm, im = S[u]['methods'], I[u]['methods']
+ if sm != im:
+ method_diff.append({'uri': u, 'nos': I[u]['nos'],
+ 'snapshot': sorted(sm), 'inventory': sorted(im),
+ 'only_live': sorted(sm - im), 'only_inv': sorted(im - sm)})
+
+ # D. app 列の不一致(URIごとに、実機の登録先と台帳の app 列を比べる)
+ app_diff = []
+ seen_no = set()
+ for u in sorted(set(S) & set(I)):
+ exp = app_expected(S[u]['apps'])
+ for no in I[u]['nos']:
+ if no in seen_no:
+ continue
+ got = rows[no][3]
+ # 「両方」行は /api 側 URI でも一致するので、片側だけ見て誤判定しないようにする
+ live_apps = set()
+ for uu in [norm(x) for x in rows[no][5].split(';') if norm(x)]:
+ for cand in (uu, norm('/api' + uu)):
+ if cand in S:
+ live_apps |= S[cand]['apps']
+ if not live_apps:
+ continue
+ exp = app_expected(live_apps)
+ if got != exp:
+ app_diff.append({'no': no, 'uri': rows[no][5], 'inventory': got, 'live': exp})
+ seen_no.add(no)
+
+ summary_only = a.summary_only
+ L = ['# スナップショット ↔ インベントリ 突き合わせ', '']
+ L.append(f"- リビジョン: `{snap['meta'].get('revision')}` {snap['meta'].get('tag')} "
+ f"経路URI={len(S)}")
+ L.append(f"- 台帳: 行={len(rows)} URI={len(I)}")
+ if summary_only:
+ L.append('')
+ L.append('> 件数のみ。詳細は秘密側の完全版レポートを参照。')
+ L.append('')
+ unexplained = len(missing) + len(phantom) + len(method_diff) + len(app_diff)
+ L.append(f"## 判定: {'❌ 未説明の差分あり' if unexplained else '✅ 一致'} ({unexplained}件)")
+ L.append('')
+ L.append('| 検出 | 件数 |')
+ L.append('|---|---:|')
+ L.append(f'| A. インベントリ未収載(抽出漏れ) | {len(missing)} |')
+ L.append(f'| B. 実機に無い(未説明) | {len(phantom)} |')
+ L.append(f'| B\'. 実機に無い(既知・許容) | {len(phantom_known)} |')
+ L.append(f'| C. メソッド不一致 | {len(method_diff)} |')
+ L.append(f'| D. app列の不一致 | {len(app_diff)} |')
+ L.append('')
+
+ if missing and not summary_only:
+ L += ['## A. インベントリ未収載 — 台帳に追加が必要', '']
+ for m in missing:
+ prov = f" [provider: {m['provider']}]" if m['provider'] else ''
+ L.append(f"- `{','.join(m['methods'])}` `{m['uri']}` — {m['key']}{prov}")
+ L.append('')
+ if phantom and not summary_only:
+ L += ['## B. 実機に無いインベントリ行 — 未説明', '']
+ for x in phantom:
+ L.append(f"- no={','.join(x['nos'])} `{x['uri']}`")
+ L.append('')
+ if method_diff and not summary_only:
+ L += ['## C. メソッド不一致', '']
+ for x in method_diff:
+ L.append(f"- no={','.join(x['nos'])} `{x['uri']}` — 実機={x['snapshot']} / "
+ f"台帳={x['inventory']}(実機のみ {x['only_live']} / 台帳のみ {x['only_inv']})")
+ L.append('')
+ if app_diff and not summary_only:
+ L += ['## D. app列の不一致', '']
+ for x in app_diff:
+ L.append(f"- no={x['no']} `{x['uri'][:70]}` — 台帳=`{x['inventory']}` / 実機=`{x['live']}`")
+ L.append('')
+ if phantom_known and not summary_only:
+ L += ["## B'. 実機に無い(既知・許容)", '']
+ for x in phantom_known:
+ L.append(f"- no={','.join(x['nos'])} `{x['uri'][:70]}` — {x['reason']}")
+ L.append('')
+
+ md = '\n'.join(L)
+ if a.out:
+ open(a.out, 'w', encoding='utf-8').write(md + '\n')
+ print(f'{a.out} を書き出しました')
+ else:
+ print(md)
+ print(f'A={len(missing)} B={len(phantom)} C={len(method_diff)} D={len(app_diff)} '
+ f"B'(既知)={len(phantom_known)}", file=sys.stderr)
+ if a.gate and unexplained:
+ sys.exit(1)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/api-inventory/scripts/reprobe_own.py b/tools/api-inventory/scripts/reprobe_own.py
new file mode 100644
index 0000000000..0b2d9af7cc
--- /dev/null
+++ b/tools/api-inventory/scripts/reprobe_own.py
@@ -0,0 +1,44 @@
+# [参考実装] 動的検証用。セッション固有の絶対パス(scratchpad/ck等)を含むため、
+# 再利用時は BASE/HOST/Cookie保存先/probe対象TSVパスを環境に合わせて修正すること。
+# 手順は tools/api-inventory/README.md の Phase 3 を参照。
+import subprocess,sys,re,json
+S="/tmp/claude-1000/-home-mhaya-wekov2/a8119b60-023e-4882-84ac-a0edcfb5627e/scratchpad/api"
+BASE="https://localhost:8443";HOST="weko3.example.org"
+def resolve(uri):
+ u=uri
+ for pat,val in [(r"","v1"),(r"","6e74ad33-e886-4c27-8d10-45a160fae30c:c7718302-39fa-472b-92aa-81e61a9d9f4d:secret.png"),
+ (r"<[^>]*api_code>","crf"),(r"<[^>]*index_id>","100100"),(r"<[^>]*journal_id>","1"),
+ (r"","top_page_access"),(r"","2026"),(r"","8"),
+ (r"<[^>]*(file_name|filename|key)>","secret.png"),(r"]+>","secret.png"),
+ (r"<[^>]*group_id>","1"),(r"<[^>]*(resync_id|repo_id|resource_id)>","1"),
+ (r"<[^>]*(pid_value|recid|identifier)>","1001"),(r"<[^>]*activity_id>","A-1"),
+ (r"<[^>]*community_id>","comm1"),(r"<[^>]*(id|Id)>","1"),(r"<[^>]+>","1")]:
+ u=re.sub(pat,val,u)
+ return u
+def login(name,email):
+ subprocess.run(["curl","-sk","-c",f"{S}/ck/{name}.txt","-o","/dev/null","--max-time","10","-H","Host: "+HOST,
+ "-X","POST",BASE+"/api/v1/login","-H","Content-Type: application/json",
+ "-d",json.dumps({"email":email,"password":"Passw0rd!123"})],capture_output=True,timeout=15)
+def curl(method,url,ck):
+ a=["curl","-sk","-o","/dev/null","-w","%{http_code}","--max-time","12","-X",method,"-H","Host: "+HOST]
+ if ck:a+=["-b",ck]
+ if method in("POST","PUT","PATCH"):a+=["-H","Content-Type: application/json","-d","{}"]
+ a.append(BASE+url)
+ try:return subprocess.run(a,capture_output=True,text=True,timeout=18).stdout.strip() or "000"
+ except:return "ERR"
+USERS={"general":"user@example.org","contributor":"contributor@example.org","comadmin":"comadmin@example.org","repoadmin":"repoadmin@example.org"}
+items=[l.rstrip("\n").split("\t") for l in open(S+"/own83.tsv")]
+out={}
+# identityごとに: 直前ログイン→全件パス(sentinelで鮮度確認)
+for ident,email in USERS.items():
+ login(ident,email); ck=f"{S}/ck/{ident}.txt"
+ sent=curl("GET","/accounts/settings/groups/1/manage",ck) # sentinel(200期待)
+ for idx,method,uri,ep in items:
+ out.setdefault(idx,{})[ident]=curl(method.split(",")[0],resolve(uri),ck)
+ sent2=curl("GET","/accounts/settings/groups/1/manage",ck)
+ print(f"{ident}: sentinel start={sent} end={sent2}",file=sys.stderr)
+# anon
+for idx,method,uri,ep in items:
+ out.setdefault(idx,{})["anon"]=curl(method.split(",")[0],resolve(uri),None)
+json.dump(out,open(sys.argv[1],"w"))
+print("done",len(out),file=sys.stderr)
diff --git a/tools/api-inventory/scripts/snapshot.py b/tools/api-inventory/scripts/snapshot.py
new file mode 100644
index 0000000000..b429c3842c
--- /dev/null
+++ b/tools/api-inventory/scripts/snapshot.py
@@ -0,0 +1,548 @@
+# -*- coding: utf-8 -*-
+"""API スナップショット生成 — 経路の正は実機 url_map、属性は AST で付与する。
+
+ python3 snapshot.py --out api_snapshot.json
+
+なぜ実機 url_map が正か:
+ AST で `@bp.route` / `add_url_rule` を全部拾っても 357件。実機は 903ルート(static除く)。
+ 差の 52% は Flask-Admin の自動生成(223) / `@expose`(約100) / config駆動 REST(約30) /
+ modules配下に無い pip パッケージ / route が式の add_url_rule / framework 由来。
+
+出力構造:
+ meta … 生成条件(リビジョン・プロファイル・件数)
+ endpoints … 経路ごとの属性 + auth_hash/body_hash
+ modelviews … ModelView の権限属性(can_delete/can_export 等。url_map には出ない)
+ config … 認可を左右する config キーのウォッチリスト
+
+`endpoints` に載っていて AST と結合できなかったものは `attrs: "unknown"` として
+明示的に残す(黙って落とさない)。差分レビューで人が見に行く導線になる。
+"""
+import argparse
+import ast
+import collections
+import hashlib
+import json
+import os
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+import warnings
+
+warnings.filterwarnings('ignore', category=SyntaxWarning)
+
+# --- audit_decorators.py と同一の認証デコレータ辞書 -------------------------
+AUTH_NAMES = {
+ 'login_required', 'login_required_customize', 'roles_required',
+ 'require_api_auth', 'require_oauth_scopes', 'need_record_permission',
+ 'need_permissions', 'check_authority', 'stats_api_access_required',
+ 'check_index_access_permissions', 'check_on_behalf_of', 'require_oauth',
+ 'pass_record',
+}
+
+# --- 認可を左右する config キー(値が危険側に倒れたら FAIL) -----------------
+CONFIG_WATCH = [
+ (r'.*_PERMISSION_FACTORY(_IMP)?$', '*_PERMISSION_FACTORY'),
+ (r'^[A-Z_]*REST_ENDPOINTS$', 'REST endpoint 定義'),
+ (r'^WTF_CSRF_ENABLED$', 'CSRF保護'),
+ (r'^CSRF_ENABLED$', 'CSRF保護'),
+ (r'^LOGIN_DISABLED$', '認証の全無効化'),
+ (r'^WEKO_ITEMS_UI_SHARED_USER_ROLE_ID_LIST$', '共有ユーザ候補範囲'),
+ (r'^WEKO_PERMISSION_.*', 'ロール定義'),
+ (r'^WEKO_ADMIN_ACCESS_TABLE$', 'Flask-Admin ロール制御表'),
+ (r'^ACCOUNTS_.*ENABLED$', 'アカウント機能の有効/無効'),
+]
+
+SKIP_DIRS = ('/tests', '/examples', '/.tox', '/node_modules', '/cookiecutter', '/docs/', '/build/')
+
+# --- コンテナ内で実行するダンプスクリプト ----------------------------------
+DUMP_PY = r'''
+import json
+from flask import current_app as a
+
+def rules(app, tag, out):
+ for r in app.url_map.iter_rules():
+ vf = app.view_functions.get(r.endpoint)
+ out.append({
+ "app": tag,
+ "endpoint": r.endpoint,
+ "methods": sorted(m for m in r.methods if m not in ("HEAD", "OPTIONS")),
+ "rule": str(r),
+ "module": getattr(vf, "__module__", "") or "",
+ "func": getattr(vf, "__name__", "") or "",
+ })
+
+out = []
+rules(a, "ui", out)
+mounts = getattr(a.wsgi_app, "mounts", {}) or {}
+for prefix, sub in mounts.items():
+ app2 = getattr(sub, "__self__", None) or sub
+ if hasattr(app2, "url_map"):
+ rules(app2, prefix.strip("/") or "sub", out)
+
+def safe(obj, name):
+ """can_* は権限を実行時評価する property のことがあるので必ず握る。"""
+ try:
+ return getattr(obj, name, None)
+ except Exception as exc:
+ return "" % type(exc).__name__
+
+mv = []
+admin_exts = a.extensions.get("admin") or []
+for adm in admin_exts:
+ for v in getattr(adm, "_views", []):
+ model = safe(v, "model")
+ if model is None or isinstance(model, str):
+ continue
+ mv.append({
+ "endpoint": safe(v, "endpoint"),
+ "cls": type(v).__name__,
+ "model": getattr(model, "__name__", None),
+ "table": getattr(model, "__tablename__", None),
+ "can_create": safe(v, "can_create"),
+ "can_edit": safe(v, "can_edit"),
+ "can_delete": safe(v, "can_delete"),
+ "can_export": safe(v, "can_export"),
+ "can_view_details": safe(v, "can_view_details"),
+ "column_export_list": [str(x) for x in (safe(v, "column_export_list") or [])],
+ })
+
+# 外部ライブラリが登録した経路を、どの配布物の何版が持ち込んだかに帰着させる。
+# 依存の更新で経路が増減したときに原因を特定できる。
+pkgs = {}
+modmap = {}
+try:
+ import pkg_resources
+ for dist in pkg_resources.working_set:
+ pkgs[dist.project_name] = dist.version
+ try:
+ tops = dist.get_metadata("top_level.txt").split()
+ except Exception:
+ tops = [dist.project_name.replace("-", "_")]
+ for t in tops:
+ modmap.setdefault(t, dist.project_name)
+except Exception as exc:
+ pkgs = {"__error__": str(exc)}
+
+json.dump({"rules": out, "modelviews": mv, "packages": pkgs, "module_to_package": modmap},
+ open("/tmp/_snapshot_dump.json", "w"), ensure_ascii=False, indent=1)
+print("rules=%d modelviews=%d packages=%d" % (len(out), len(mv), len(pkgs)))
+'''
+
+
+def default_weko_root():
+ """解析対象リポジトリのルートを決める。
+
+ 1. 環境変数 WEKO_ROOT
+ 2. このスクリプトを含む git リポジトリのトップ(`modules/` があれば採用)
+ → WEKO3 リポジトリ内(例: tools/api-inventory/scripts/)に配置した場合に効く
+ 3. 従来の既定(weko-document から wekov2 を解析する場合)
+ """
+ env = os.environ.get('WEKO_ROOT')
+ if env:
+ return env
+ here = os.path.dirname(os.path.abspath(__file__))
+ try:
+ top = subprocess.run(['git', '-C', here, 'rev-parse', '--show-toplevel'],
+ capture_output=True, text=True).stdout.strip()
+ except Exception:
+ top = ''
+ if top and os.path.isdir(os.path.join(top, 'modules')):
+ return top
+ return '/home/mhaya/wekov2'
+
+
+def sh(cmd, **kw):
+ return subprocess.run(cmd, shell=isinstance(cmd, str), capture_output=True,
+ text=True, **kw)
+
+
+def list_web_containers():
+ """compose の service=web ラベルを持つ起動中コンテナ名。"""
+ r = sh(['docker', 'ps', '--filter', 'label=com.docker.compose.service=web',
+ '--format', '{{.Names}}'])
+ return [x for x in r.stdout.split('\n') if x.strip()]
+
+
+def resolve_container(name):
+ """web コンテナを解決する。省略時は compose ラベルから自動検出する。
+
+ `docker compose -f X.yml ps -q web` は X.yml のプロジェクト名でしか探さないため、
+ 別ファイル/別プロジェクト名で起動したスタックでは空になる
+ (例: install.sh は docker-compose2.yml=project wekov2、
+ 手動起動は docker-compose.yml -p weko)。ここは compose ラベルで直接探す。
+ """
+ if name:
+ r = sh(['docker', 'inspect', '-f', '{{.State.Running}}', name])
+ if r.stdout.strip() == 'true':
+ return name
+ cands = list_web_containers()
+ sys.exit(f"コンテナ '{name}' が見つからないか停止しています。\n"
+ f" 起動中の web コンテナ: {', '.join(cands) if cands else '(なし)'}")
+
+ cands = list_web_containers()
+ if len(cands) == 1:
+ print(f' コンテナを自動検出: {cands[0]}')
+ return cands[0]
+ if not cands:
+ sys.exit('web コンテナが見つかりません。スタックを起動してください。\n'
+ ' 例: ./install.sh / docker compose -p weko up -d web\n'
+ ' 起動済みなら --container <名前> を明示してください。')
+ sys.exit('web コンテナが複数あります。--container で指定してください:\n '
+ + '\n '.join(cands))
+
+
+def live_dump(container, workdir):
+ """実機 url_map と ModelView 属性をコンテナから取得する。"""
+ container = resolve_container(container)
+ local = os.path.join(workdir, '_dump.py')
+ with open(local, 'w', encoding='utf-8') as f:
+ f.write(DUMP_PY)
+ r = sh(['docker', 'cp', local, f'{container}:/tmp/_dump.py'])
+ if r.returncode:
+ sys.exit(f'docker cp 失敗: {r.stderr.strip()}')
+ r = sh([
+ 'docker', 'exec', container, 'bash', '-lc',
+ 'source ~/.virtualenvs/invenio/bin/activate; cd /code; '
+ 'invenio shell -c "exec(open(\'/tmp/_dump.py\').read())"',
+ ])
+ if 'rules=' not in r.stdout:
+ sys.exit(f'url_map ダンプ失敗:\n{r.stdout[-2000:]}\n{r.stderr[-2000:]}')
+ print(' ' + [l for l in r.stdout.splitlines() if l.startswith('rules=')][-1])
+ out = os.path.join(workdir, '_snapshot_dump.json')
+ r = sh(['docker', 'cp', f'{container}:/tmp/_snapshot_dump.json', out])
+ if r.returncode:
+ sys.exit(f'docker cp 取得失敗: {r.stderr.strip()}')
+ with open(out, encoding='utf-8') as f:
+ return json.load(f)
+
+
+# --- AST 側 -----------------------------------------------------------------
+def dec_name(d):
+ c = d.func if isinstance(d, ast.Call) else d
+ parts = []
+ while isinstance(c, ast.Attribute):
+ parts.append(c.attr)
+ c = c.value
+ if isinstance(c, ast.Name):
+ parts.append(c.id)
+ return '.'.join(reversed(parts))
+
+
+def dec_repr(d):
+ name = dec_name(d)
+ if not isinstance(d, ast.Call):
+ return name
+ args = []
+ for a in d.args:
+ try:
+ args.append(repr(ast.literal_eval(a)))
+ except Exception:
+ args.append(ast.unparse(a))
+ for k in d.keywords:
+ try:
+ args.append(f'{k.arg}={ast.literal_eval(k.value)!r}')
+ except Exception:
+ args.append(f'{k.arg}={ast.unparse(k.value)}')
+ return f"{name}({', '.join(args)})"
+
+
+def is_auth_dec(name):
+ last = name.split('.')[-1]
+ return (last in AUTH_NAMES
+ or name.endswith('permission.require')
+ or 'require' in last)
+
+
+def is_route_dec(name):
+ last = name.split('.')[-1]
+ return last in ('route', 'expose', 'add_url_rule')
+
+
+def dotted(rel_path):
+ """modules/weko-admin/weko_admin/views.py -> weko_admin.views"""
+ p = rel_path[:-3] if rel_path.endswith('.py') else rel_path
+ parts = p.split('/')
+ if parts[:1] == ['modules']:
+ parts = parts[2:] # modules// を落とす
+ if parts and parts[-1] == '__init__':
+ parts = parts[:-1]
+ return '.'.join(parts)
+
+
+def scan_ast(root):
+ """(module, funcname) -> 属性 の索引を作る。route デコレータの有無は問わない。
+
+ `@expose` や as_view 経由でも実装本体は掴めるようにするため、全 def を拾う。
+ """
+ index = {}
+ commented = collections.defaultdict(list)
+ for dirpath, _dirnames, filenames in os.walk(os.path.join(root, 'modules')):
+ if any(s in dirpath + '/' for s in SKIP_DIRS):
+ continue
+ for fn in sorted(filenames):
+ if not fn.endswith('.py'):
+ continue
+ fp = os.path.join(dirpath, fn)
+ rel = os.path.relpath(fp, root)
+ try:
+ text = open(fp, encoding='utf-8', errors='replace').read()
+ tree = ast.parse(text)
+ except Exception:
+ continue
+ lines = text.splitlines()
+ mod = dotted(rel)
+
+ # コメントアウトされた認証/認可デコレータ(no.34 の IIIF がこれ)
+ for i, line in enumerate(lines, 1):
+ s = line.strip()
+ if s.startswith('#') and '@' in s:
+ body = s.lstrip('#').strip()
+ if body.startswith('@'):
+ nm = body[1:].split('(')[0]
+ if is_auth_dec(nm) or 'permission' in nm:
+ commented[mod].append({'line': i, 'text': body[:120]})
+
+ def record(node, cls=None):
+ decs = [dec_repr(d) for d in node.decorator_list]
+ auth = sorted({d for d in decs
+ if is_auth_dec(dec_name_of(d)) and not is_route_dec(dec_name_of(d))})
+ start = min([node.lineno] + [d.lineno for d in node.decorator_list])
+ end = getattr(node, 'end_lineno', node.lineno)
+ body = '\n'.join(l.rstrip() for l in lines[start - 1:end])
+ info = {
+ 'impl': f'{rel}:{node.lineno}',
+ 'decorators': decs,
+ 'auth_decorators': auth,
+ 'auth_hash': sha1('\n'.join(auth)),
+ 'body_hash': sha1(body),
+ 'lines': [start, end],
+ }
+ index.setdefault((mod, node.name), info)
+ if cls:
+ index[(mod, f'{cls}.{node.name}')] = info
+
+ for node in ast.walk(tree):
+ if isinstance(node, ast.ClassDef):
+ for b in node.body:
+ if isinstance(b, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ record(b, cls=node.name)
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ record(node)
+ return index, commented
+
+
+def dec_name_of(repr_str):
+ return repr_str.split('(')[0]
+
+
+def sha1(s):
+ return hashlib.sha1(s.encode('utf-8')).hexdigest()[:12]
+
+
+def scan_config(root):
+ """認可を左右する config キーを収集する(config 経由の認可無効化の検知用)。"""
+ out = {}
+ pats = [(re.compile(p), label) for p, label in CONFIG_WATCH]
+ for dirpath, _dn, filenames in os.walk(os.path.join(root, 'modules')):
+ if any(s in dirpath + '/' for s in SKIP_DIRS):
+ continue
+ if 'config.py' not in filenames:
+ continue
+ fp = os.path.join(dirpath, 'config.py')
+ rel = os.path.relpath(fp, root)
+ try:
+ tree = ast.parse(open(fp, encoding='utf-8', errors='replace').read())
+ except Exception:
+ continue
+ for node in tree.body:
+ if not (isinstance(node, ast.Assign) and len(node.targets) == 1
+ and isinstance(node.targets[0], ast.Name)):
+ continue
+ key = node.targets[0].id
+ label = next((lb for rx, lb in pats if rx.match(key)), None)
+ if not label:
+ continue
+ try:
+ val = repr(ast.literal_eval(node.value))
+ except Exception:
+ val = ast.unparse(node.value)
+ out[f'{rel}::{key}'] = {
+ 'label': label, 'line': node.lineno,
+ 'value': val if len(val) <= 300 else val[:300] + '...',
+ 'value_hash': sha1(val),
+ }
+ return out
+
+
+def build(args):
+ root = os.path.abspath(args.weko_root)
+ # 中間ファイル(_dump.py / _snapshot_dump.json)は既定で一時ディレクトリに置く。
+ # 出力先ディレクトリに書くとリポジトリを汚す。
+ tmp = None
+ if args.workdir:
+ work = args.workdir
+ os.makedirs(work, exist_ok=True)
+ else:
+ tmp = tempfile.mkdtemp(prefix='api-snapshot-')
+ work = tmp
+
+ print('[1/4] 実機 url_map / ModelView をダンプ')
+ if args.dump:
+ with open(args.dump, encoding='utf-8') as f:
+ dump = json.load(f)
+ else:
+ dump = live_dump(args.container, work)
+
+ print('[2/4] AST で実装属性を索引化')
+ index, commented = scan_ast(root)
+ print(f' 索引: {len(index)} 関数 / コメントアウト認証: '
+ f'{sum(len(v) for v in commented.values())} 箇所')
+
+ print('[3/4] config ウォッチリストを収集')
+ conf = scan_config(root)
+ print(f' 監視対象キー: {len(conf)}')
+
+ print('[4/4] 結合')
+ pkgs = dump.get('packages', {})
+ modmap = dump.get('module_to_package', {})
+ mod_root = os.path.join(root, 'modules')
+ local_dists = set()
+ if os.path.isdir(mod_root):
+ for name in os.listdir(mod_root):
+ local_dists.add(name)
+ local_dists.add(name.replace('_', '-'))
+
+ # 1エンドポイントに複数ルールが付くことがある:
+ # ・末尾スラッシュ違い / 省略可能パラメータ
+ # ・同じ view_func を add_url_rule で複数回登録(endpoint 名が同一になる)
+ # 後者はルールごとにメソッドが異なるため、メソッドを endpoint 単位で union しては
+ # ならない(例: weko_index_tree_rest.ima は create=POST / update=PUT / delete=DELETE を
+ # それぞれ別ルールで持つ)。ルール単位で保持する。
+ grouped = {}
+ for r in dump['rules']:
+ if r['func'] == 'send_static_file' or r['endpoint'].endswith('.static'):
+ continue
+ g = grouped.setdefault(f"{r['app']}:{r['endpoint']}", dict(r, routes={}))
+ g['routes'].setdefault(r['rule'], set()).update(r['methods'])
+
+ endpoints = {}
+ unknown = 0
+ for key, r in grouped.items():
+ routes = [{'rule': rule, 'methods': sorted(ms)}
+ for rule, ms in sorted(r['routes'].items())]
+ e = {
+ 'app': r['app'],
+ 'endpoint': r['endpoint'],
+ 'routes': routes, # ルール単位(正)
+ 'rules': [x['rule'] for x in routes], # 概観用
+ 'methods': sorted({m for x in routes for m in x['methods']}), # 概観用
+ 'view': f"{r['module']}.{r['func']}" if r['module'] else r['func'],
+ }
+ top = (r['module'] or '').split('.')[0]
+ dist = modmap.get(top)
+ if dist and dist not in local_dists:
+ # modules/ に無い = 外部ライブラリが登録した経路。
+ # ソース走査では原理的に見えないので、実機ダンプでしか捕捉できない。
+ e['provider'] = f'{dist}=={pkgs.get(dist, "?")}'
+ hit = index.get((r['module'], r['func']))
+ if hit is None and '.' in r['func']:
+ hit = index.get((r['module'], r['func'].split('.')[-1]))
+ if hit:
+ e.update({
+ 'attrs': 'ast',
+ 'impl': hit['impl'],
+ 'decorators': hit['decorators'],
+ 'auth_decorators': hit['auth_decorators'],
+ 'auth_hash': hit['auth_hash'],
+ 'body_hash': hit['body_hash'],
+ })
+ near = [c for c in commented.get(r['module'], [])
+ if hit['lines'][0] - 6 <= c['line'] <= hit['lines'][1]]
+ if near:
+ e['commented_auth'] = near
+ else:
+ # ④ 経路はあるが静的解析で属性が取れないもの。黙って落とさない。
+ e['attrs'] = 'unknown'
+ e['reason'] = classify_unknown(r)
+ unknown += 1
+ endpoints[key] = e
+
+ mvs = {m['endpoint']: m for m in dump.get('modelviews', []) if m.get('endpoint')}
+
+ rev = sh(['git', '-C', root, 'rev-parse', '--short', 'HEAD']).stdout.strip()
+ tag = sh(['git', '-C', root, 'describe', '--tags']).stdout.strip()
+
+ snap = {
+ 'meta': {
+ 'weko_root': root,
+ 'revision': rev,
+ 'tag': tag,
+ 'profile': args.profile,
+ 'counts': {
+ 'endpoints': len(endpoints),
+ 'attrs_ast': len(endpoints) - unknown,
+ 'attrs_unknown': unknown,
+ 'modelviews': len(mvs),
+ 'config_keys': len(conf),
+ 'external_endpoints': sum(1 for e in endpoints.values() if e.get('provider')),
+ 'packages': len(pkgs),
+ 'commented_auth': sum(len(v) for v in commented.values()),
+ },
+ },
+ # 依存の版。経路の増減を依存更新に帰着させるために保持する。
+ 'packages': dict(sorted(pkgs.items())),
+ 'endpoints': dict(sorted(endpoints.items())),
+ 'modelviews': dict(sorted(mvs.items())),
+ 'config': dict(sorted(conf.items())),
+ # エンドポイントに紐付かないコメントアウト認証も残す。
+ # no.34 の IIIF `protect_api` はビュー関数ではなくハンドラフックなので、
+ # エンドポイント単位の検査だけでは捕まらない。
+ 'commented_auth': {
+ mod: sorted(items, key=lambda x: x['line'])
+ for mod, items in sorted(commented.items())
+ },
+ }
+ with open(args.out, 'w', encoding='utf-8') as f:
+ json.dump(snap, f, ensure_ascii=False, indent=1, sort_keys=False)
+ f.write('\n')
+ if tmp:
+ shutil.rmtree(tmp, ignore_errors=True)
+ c = snap['meta']['counts']
+ print(f"\n{args.out}: endpoints={c['endpoints']} "
+ f"(AST結合={c['attrs_ast']} / 属性不明={c['attrs_unknown']}) "
+ f"modelviews={c['modelviews']} config={c['config_keys']} rev={rev} {tag}")
+ return snap
+
+
+def classify_unknown(r):
+ """属性が取れなかった理由を推定する(差分レビューの手がかり)。"""
+ mod = r['module']
+ if mod.startswith('flask_admin'):
+ return 'Flask-Admin ModelView 自動生成'
+ if mod.startswith('flask_security') or mod.startswith('flask_login'):
+ return 'flask-security/login 由来'
+ if mod.startswith('flask'):
+ return 'framework 由来'
+ if '_rest' in mod or mod.endswith('.views') and r['func'].islower() is False:
+ return 'config駆動 REST の可能性'
+ if not mod.startswith(('weko_', 'invenio_')):
+ return 'modules 配下に無いパッケージ'
+ return 'AST未結合(as_view / 動的登録 / pip側パッケージ)'
+
+
+def main():
+ p = argparse.ArgumentParser(description='API スナップショットを生成する')
+ p.add_argument('--out', default='api_snapshot.json')
+ p.add_argument('--weko-root', default=default_weko_root())
+ p.add_argument('--container', default='',
+ help='実機ダンプ元のコンテナ名(省略時は compose ラベルから自動検出)')
+ p.add_argument('--dump', help='ダンプ済み JSON を使う(コンテナ起動不要)')
+ p.add_argument('--profile', default='default', help='設定プロファイル名(条件付き登録の差を区別する)')
+ p.add_argument('--workdir', help='中間ファイル置き場')
+ build(p.parse_args())
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/api-inventory/scripts/test_coverage.py b/tools/api-inventory/scripts/test_coverage.py
new file mode 100644
index 0000000000..a1c9457d84
--- /dev/null
+++ b/tools/api-inventory/scripts/test_coverage.py
@@ -0,0 +1,144 @@
+# -*- coding: utf-8 -*-
+"""Phase 9: 各エンドポイントのテストが4観点をカバーしているかを解析する。
+
+ python3 test_coverage.py
+
+観点:
+ 正常値 2xx を期待するアサーションがある
+ 異常値 4xx/5xx を期待するアサーションがある
+ 境界値 parametrize による値の振り分け、または空文字/長大値/上限下限を狙ったテスト
+ 例外処理 pytest.raises / assertRaises による例外の検証
+
+台帳の `test_file`(対応テスト) と `impl_func` を突き合わせ、そのエンドポイントに
+関係するテスト関数を特定してから観点を判定する。ファイル単位で見ると、同じ
+ファイル内の別APIのテストを自分のものとして数えてしまうため。
+
+**これは静的なキーワード判定であり、テストの十分性を保証するものではない。**
+「観点が全く見当たらない」ことの検出には使えるが、「観点がある」は
+アサーションの存在を示すだけで、内容の妥当性は見ていない。
+"""
+import argparse
+import ast
+import os
+import re
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from paths import data_path # noqa: E402
+from snapshot import default_weko_root # noqa: E402
+
+RE_OK = re.compile(r'status_code\s*(==|in)\s*\(?\s*(200|201|202|204)')
+RE_NG = re.compile(r'status_code\s*(==|in)\s*\(?\s*(400|401|403|404|405|409|410|412|415|422|500)')
+RE_EXC = re.compile(r'pytest\.raises|assertRaises|with\s+raises\(')
+RE_BOUND_NAME = re.compile(
+ r'boundary|limit|max|min|empty|too_?long|overflow|invalid_length|zero|negative|'
+ r'境界|上限|下限|空', re.I)
+RE_BOUND_BODY = re.compile(
+ r'@pytest\.mark\.parametrize|["\']\s*["\']\s*[,)]|\*\s*\d{3,}|'
+ r'sys\.maxsize|float\(["\']inf|-1\s*[,)]')
+
+
+def norm_static(uri):
+ """URI から検索に使える静的部分を取り出す(最長のパラメータなし区間)。"""
+ parts = [p for p in uri.split('/') if p and '<' not in p]
+ return parts[-1] if parts else ''
+
+
+def collect_tests(path):
+ """テストファイルから {関数名: ソース} を返す。"""
+ try:
+ text = open(path, encoding='utf-8', errors='replace').read()
+ tree = ast.parse(text)
+ except Exception:
+ return {}
+ lines = text.splitlines()
+ out = {}
+ for node in ast.walk(tree):
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) \
+ and node.name.startswith('test'):
+ start = min([node.lineno] + [d.lineno for d in node.decorator_list])
+ end = getattr(node, 'end_lineno', node.lineno)
+ out[node.name] = '\n'.join(lines[start - 1:end])
+ return out
+
+
+def analyse(row_tests):
+ """関係するテスト群から4観点の有無を判定する。"""
+ joined = '\n'.join(row_tests.values())
+ names = ' '.join(row_tests)
+ return {
+ 'normal': bool(RE_OK.search(joined)),
+ 'abnormal': bool(RE_NG.search(joined)),
+ 'boundary': bool(RE_BOUND_BODY.search(joined)) or bool(RE_BOUND_NAME.search(names)),
+ 'exception': bool(RE_EXC.search(joined)),
+ }
+
+
+def main():
+ p = argparse.ArgumentParser(description='テスト4観点のカバレッジを台帳に付与')
+ p.add_argument('--full', default=None)
+ p.add_argument('--weko-root', default=None)
+ p.add_argument('--dry-run', action='store_true')
+ a = p.parse_args()
+ full = a.full or data_path('weko3_api_list_full.tsv')
+ root = a.weko_root or default_weko_root()
+
+ lines = open(full, encoding='utf-8').read().rstrip('\n').split('\n')
+ hdr = lines[0].split('\t')
+ H = {n: i for i, n in enumerate(hdr)}
+ NEW = ['test_normal', 'test_abnormal', 'test_boundary', 'test_exception', 'test_gap']
+ keep = [i for i, n in enumerate(hdr) if n not in NEW]
+ hdr = [hdr[i] for i in keep]
+
+ cache = {}
+ out = ['\t'.join(hdr + NEW)]
+ stats = {k: 0 for k in ('normal', 'abnormal', 'boundary', 'exception')}
+ nomatch = 0
+ for raw in lines[1:]:
+ c0 = raw.split('\t')
+ c = [(c0 + [''] * len(H))[i] for i in keep]
+ Hn = {n: i for i, n in enumerate(hdr)}
+ tf = c[Hn['test_file']]
+ impl = c[Hn['impl_func']].split('.')[-1]
+ static = norm_static(c[Hn['uri']])
+
+ related = {}
+ for f in [x.strip() for x in tf.split(';') if x.strip() and x.strip() != '-']:
+ fp = os.path.join(root, f)
+ if fp not in cache:
+ cache[fp] = collect_tests(fp)
+ for name, src in cache[fp].items():
+ if (impl and impl in src) or (static and static in src) \
+ or (impl and impl in name):
+ related[f'{f}::{name}'] = src
+ if not related:
+ # 「テストが無い」のではなく「どのテスト関数が対応するか特定できなかった」。
+ # 両者を同じ '-' にすると、実際にテストが無い行と区別できなくなる。
+ nomatch += 1
+ out.append('\t'.join(c + ['?', '?', '?', '?', '特定不能']))
+ continue
+ res = analyse(related)
+ for k, v in res.items():
+ stats[k] += 1 if v else 0
+ gap = [ja for k, ja in (('normal', '正常値'), ('abnormal', '異常値'),
+ ('boundary', '境界値'), ('exception', '例外処理'))
+ if not res[k]]
+ out.append('\t'.join(c + ['○' if res['normal'] else '-',
+ '○' if res['abnormal'] else '-',
+ '○' if res['boundary'] else '-',
+ '○' if res['exception'] else '-',
+ ','.join(gap) if gap else '-']))
+
+ if not a.dry_run:
+ open(full, 'w', encoding='utf-8').write('\n'.join(out) + '\n')
+ n = len(lines) - 1
+ judged = n - nomatch
+ print(f'{"(dry-run) " if a.dry_run else ""}{full}: {n} 行を解析')
+ print(f' テスト関数を特定できた行: {judged} / 特定不能: {nomatch}')
+ for k, ja in (('normal', '正常値'), ('abnormal', '異常値'),
+ ('boundary', '境界値'), ('exception', '例外処理')):
+ print(f' {ja:<6} あり {stats[k]:>4} / なし {judged - stats[k]:>4} (特定できた {judged} 行中)')
+
+
+if __name__ == '__main__':
+ main()