aegis-api-token-security について
インターネット公開 HTTP API の不透明な Bearer/API トークンを監査するセキュリティスキルです。生成から保存、転送、権限、失効、レート制限、ログまでを読み取り専用で確認します。
主な機能
- 01トークンのエントロピーと生成を確認
- 02サーバー側の保存方法を評価
- 03転送方法と権限範囲を確認
- 04有効期限・ローテーション・失効を評価
- 05レート制限とログを点検
活用シーン
- 01マシンクライアント用 API 認証情報の監査
- 02トークンの平文保存リスクの発見
- 03漏えい後の失効能力の評価
- 04レート制限とログの機密情報の確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ as-foss/ aegis-skills --skill aegis-api-token-securityインストール後、Claude Code で/aegis-api-token-securityを入力して読み込まれたか確認します。
同梱ファイルreferences/background.mdreferences/further-reading.md
詳細
スコープと目的
インターネットに公開され、Authorization: Bearer <token> または API キーヘッダーで提示される不透明なベアラートークンを使ってマシンクライアントを認証する、単一の HTTP API について、トークンのライフサイクルの全段階を監査します。優先順位順に、以下の 10 項目を確認します。
- 設計 — エントロピーの下限(CSPRNG による 128 ビット以上)、形式の慣例(種別プレフィックス、チェックサム)、使用禁止のトークン生成元。
- サーバー側ストレージ — 従来からある致命的な欠陥:データベース内の平文または可逆暗号化されたトークンと、ハッシュのみを保存する方式の比較。
- 転送 — TLS のみを強制しているか、ヘッダー、クエリ文字列、Cookie のどれで運ぶか。
- スコープと権限モデル — すべての権限を持つトークンと、スコープ制限、テナントへの紐付け、読み取り/書き込み分離されたキーの比較。
- 有効期限とローテーション — 有効期間のポリシー、重複期間を設けて停止時間なしでローテーションする方法、古いトークンの整理。
- 失効とインシデント対応の準備 — キャッシュによる認証の空白を含む失効の遅延、漏えい対応ランブック。
- レート制限と不正利用対策 — キー単位のクォータ、429 のセマンティクス、未認証エンドポイントの分離。
- キー列挙への耐性 — 無効なトークンに対するタイミングとエラーの挙動が一様であること。
- ログ記録と監視 — 安全なフィールド、トークン全体を決してログに記録しないルール、異常のアラート。
- クライアントの衛生管理とホスト上の拡散 — 配布に関するガイダンスと、ホスト上でトークンが隠れている場所(HSECRET への橋渡し)。
対象外(相互参照):JWT の構造、アルゴリズム、none/弱いシークレットの検証 → AUTHN(skills/code/authn-session/SKILL.md);Cookie で認証情報を運ぶことによる CSRF の影響 → WEB(skills/code/web-client/SKILL.md);一般的な API の不正利用、ページネーション/列挙、未認証エンドポイントの強化 → API(skills/code/api-security/SKILL.md);エッジでの TLS 終端とヘッダーの削除 → TLS(skills/server/tls-proxy/SKILL.md);ファイル/リポジトリ内のシークレットの内容 → SECRETS(skills/code/secrets-data-exposure/SKILL.md);ボリュームベースのしきい値 → DoS(skills/code/denial-of-service/SKILL.md)。このプレイブックでログ監視モジュールを指定している場合、そのモジュールが手元のコピーにあるときは、アラートのルーティングに関するガイダンスを適用してください。
運用ルール:
- すべての調査は読み取り専用です。変更を伴うコマンドは「修正」の項目にのみ記載し、Phase 6 の承認が必要です。
- root 権限が必要なコマンドには
[ROOT]、アプリケーションデータベースの読み取り認証情報が必要なコマンドには[APP-DB]を付けます。これらの権限がない場合は、設定をコードとして管理するリポジトリから監査し、「ホスト上では検証できず」としてギャップを報告してください。 - 実効状態を判定します。実行されているミドルウェアコード、デプロイ済みのスキーマ、実際に書き込まれているログを確認してください。設定の意図は二次的な証拠です。
前提条件と用語
予備知識がなくても読める入門として、このモジュールで使う用語をそれぞれ 1 行で説明します。各用語についてのより詳しい平易な説明は、リポジトリの GUIDE.md の用語集にあります。
- ベアラートークン:この文字列を持っている人がログインしていることを意味する認証情報です。
- エントロピー:ビット数で測る予測困難さです。生成するトークンには CSPRNG による 128 ビット以上のエントロピーが必要です。
- ハッシュのみのストレージ:各トークンの暗号学的ハッシュのみを保持する方式です。データベースが漏えいしても、ログインに使える認証情報は得られません。
- スコープ付きキー:すべての操作を許可する、すべての権限を持つトークンとは異なり、指定された操作と読み取り/書き込みレベルに制限されたトークンです。
- 失効の遅延:「失効」の実行からトークンが実際に使えなくなるまでの遅延です(途中にあるキャッシュの TTL を含みます)。
- ローテーションの重複期間:切り替えの間、古いトークンと新しいトークンの両方を有効にして、クライアントが停止時間なしで更新できるようにする期間です。
- キー単位のレート制限:トークンごとに独立したリクエストクォータを適用する制限です。
- 汚染データのフロー/ソース→シンク:信頼できないデータが入口から危険な関数まで移動する経路です。
- 検出事項のステータス:Confirmed > Probable > Needs-Review。証拠に関するルールは
templates/finding-report.mdにあります。
メンタルモデル
攻撃者に必要なのは、有効なトークンが1つだけです。ライフサイクルの各段階にある弱点が、それを入手するための別々の手段になります。
Attacker goal: obtain ONE valid token ──▶ replay it from anywhere on the internet
How they get it Why it was possible (this module)
------------------------------------ -------------------------------------------------
SQLi / leaked DB backup ◀── tokens stored plaintext in DB (storage) = fatal
grep /var/log on any one box ◀── full tokens written to access/app logs
Referer header / browser history ◀── tokens carried in the query string
public CI logs / client repos ◀── hardcoded tokens, no env-var guidance to users
online guessing ◀── low-entropy minting + no per-key rate limit
ex-contractor's old key ◀── no expiry, no revocation path, no last-used signal
cached authz after revoke ◀── revocation "done" but token keeps working to TTL
ほとんどの判定は2つの重要な洞察に基づいていますが、エージェントはどちらも両方向に誤解しています。
- ハッシュの選択はコンテキストによって異なります。 人間は推測されやすい秘密情報を選ぶため、パスワードデータベースには低速なソルト付きKDF(argon2/bcrypt)が必要です。KDFの役割はそれ以外にありません。128ビット以上のCSPRNGエントロピーを持つAPIトークンには推測可能な構造がないため、保存時の保護には
SHA-256(token)で十分です。これを逆算するには2^128以上の候補を探索する必要があり、実行不可能だからです。ここでargon2を使っても「セキュリティが高まる」わけではありません。リクエストごとに約100 msの負荷がかかり、認証エンドポイントへのDoSを招くだけです。逆に、SHA-256("password123")では何も保護できません。判定の基準はアルゴリズムの流行ではなく、入力のエントロピーです。 - 後から有効になる失効は、失効ではありません。 トークンのチェックと権限テーブルの間に正のTTLを持つキャッシュがあると、「失効済み」は「N秒以内に失効」になります。コードからNを必ず算出してください。
このモジュール全体のトリアージで使用するライフサイクル段階の見方:
| ライフサイクル段階 | 欠陥 | 検出方法 | 重大度の目安 |
|---|---|---|---|
| 発行 | エントロピーが128ビット未満、またはCSPRNG以外の乱数(Math.random、Mersenne Twister) | 発行コードを読み、log2(alphabet^len)を計算する | 高(短く、レート制限もない場合はクリティカル) |
| 発行 | uuid4のみのトークン | 発行処理でのrandomUUID()/uuid4() | 境界的 — What To Check #1を参照 |
| 発行 | タイププレフィックス/チェックサムがない | トークン形式のサンプルを確認する | 低 |
| 発行 | ユーザーID、メールアドレス、タイムスタンプ、カウンターから導出 | 発行コードを読む | 高 |
| 保存 | トークンの列が平文/可逆形式 | スキーマを調査する。対応する*_hash列がない | クリティカル |
| 保存 | ハッシュが切り詰められている(32バイト未満)、またはソルトなしのMD5/SHA-1 | 列サイズ/コード内のアルゴリズムを確認する | 高 |
| 転送 | クエリ文字列での送信 | アクセスログでtoken=パラメーターを検索する | 高 |
| 転送 | 平文HTTPでのトークン受け入れ | リスナー/プロキシの設定を確認する(TLSと相互参照) | 高 |
| スコープ | インテグレーション間で共有される単一の万能トークン。スコープ列がない | クライアント一覧とトークン行を照合する | 高 |
| 有効期限 | すべてのexpires_atがNULLで、失効機能もない | DDLと管理機能の一覧を確認する | 高 |
| ローテーション | ローテーションに同時切り替え(ダウンタイムまたは障害)が必要 | ランブック/インテグレーションのインシデント履歴を確認する | 中 |
| 失効 | 認証キャッシュに失効時の無効化処理がない | キャッシュTTLのコードパスを読む | 高 |
| 制限 | キー単位のクォータがなく、429も返されない | 設定を確認する。ドキュメント用エンドポイントのレスポンスヘッダーを確認する | 中 |
| 列挙 | 無効なトークンへのレスポンスが原因によってタイミング/本文で異なる | 粗いタイミング測定を行う。エラー分類を確認する | 中 |
| ログ | ログにトークン全体が記録される | ログでBearer <20+ chars>を検索する | 高 |
| 配布 | リポジトリ/設定にトークンがハードコードされている | リポジトリ/ホストを横断して検索する | 中(SECRETSと相互参照) |
どの連鎖箇所を破るかに基づいて指摘事項を分類してください。隣接する侵入口と組み合わさると、中程度の欠陥がクリティカルになります(例:レート制限なし+8文字のトークン=オンラインで推測可能)。
確認事項
1. トークン設計:エントロピー、形式、禁止する生成元
トークンの生成コードを特定して読みます。スタックの両側を検索してください。
rg -n "randomBytes|randomUUID|uuid4|token_hex|token_urlsafe|secrets\.choice|randbelow" server/ src/ app/
rg -n "Math\.random|random\.random|random\.randint|Random\(\)|mt19937" server/ src/ app/ # non-CSPRNG = flag
基準を適用します。CSPRNG による 128 ビット以上が必要です。つまり、最低でも crypto.randomBytes(16)、できれば 32 バイトです。エントロピーは自分で計算してください。bits = log2(alphabet_size ^ length) です。43 文字の base64url 文字列は約 256 ビット、32 文字の 16 進文字列は 128 ビット、8 文字の英数字文字列は約 47.6 ビットであり、それだけで High の指摘事項になります。
uuid4 の判定は、次のとおりそのまま適用してください。UUIDv4 は CSPRNG による 122 ビットの乱数を含みます。現実的な攻撃者にとって、2^122 の総当たりは 2^128 と同様に実行不可能であるため、uuid4 由来のトークンは許容範囲ぎりぎりで、要フラグ付けです。(a)122 < 128 であり、規定の基準を下回るため、黙って合格にしてはいけません。(b)uuid4 文字列には型プレフィックスもチェックサムもないため、ログや設定ファイルでは他の UUID と区別できず、サーバーに届くまで貼り付けミスも検出されません。(c)uuid4 は汎用識別子として常用されるため、同じ値が秘密情報ではないコンテキストにもよく現れ、ユーザーがあちこちにコピーする習慣を身につけてしまいます。用途に特化したプレフィックス付き形式を推奨してください。エントロピーだけを理由に Critical と判定してはいけません。
必須の形式プロパティ(ない場合は Low の重大度ですが、必ず報告してください):
- 秘密情報の種類と環境を示す型プレフィックス:
sk_live_、pat_、ghp_など。シークレットスキャンツールでの検出、ログでの目視による振り分け、安全なプレフィックスのログ記録が可能になります。 - 末尾のチェックサム(例:
SHA-256(body)の base64url 表現の 8 文字)。入力ミスのあるトークンをローカルで拒否できるため、認証リクエストの発生や、通信上でのタイプミスの収集を防げます。
禁止する生成元:ユーザー ID、メールアドレス、タイムスタンプ、連番カウンター、ホスト名/MAC アドレス、または none/弱い対称鍵シークレットで署名された JWT に由来する、もしくはそれらを含むトークンにはフラグを付けてください(最後の項目は AUTHN の skills/code/authn-session/SKILL.md で確認してください。このモジュールでは、「JWT をベアラー資格情報として受け入れる」ことは重複監査ではなく、ポインターとしての指摘事項として扱います)。
2. サーバー側のストレージ(古典的な致命的欠陥)
デプロイ済みのスキーマ [APP-DB] を調べます。
SELECT table_name, column_name, data_type, character_maximum_length
FROM information_schema.columns
WHERE column_name ~* '(token|api_key|apikey|secret|credential)'
ORDER BY table_name, ordinal_position;
判定ルール:
- API がその値を受け入れる状態で、認証可能なトークンそのものを保持するカラム(名前に
_hash/digestを含まず、トークン全文が格納されている)がある場合 → Critical。値は決して取得せず、長さだけをサンプリングして確認します。SELECT length(secret_col), count(*) FROM t GROUP BY 1;を実行し、生成されたトークンと一致する長さがそろっていれば、機密情報を露出せずに裏付けられます。 - 可逆暗号化によるストレージ(AES など。鍵がアプリ設定にある場合)→ High:復号鍵がすぐ隣にあるため、アプリケーションレベルで読み取り可能なプリミティブがあれば制御が破綻します。「鍵の侵害時には平文と同等」として報告してください。
- 正しい形式:
SHA-256(raw_token)(32 バイトのダイジェスト)を格納する、信頼できる唯一のカラムtoken_hash/token_digestがあり、UNIQUE インデックスで保護されていること。SHA-256 がここで十分で、argon2 は不要な理由:入力エントロピーが 128 ビット以上であれば必要なのはプリイメージ耐性だけです。推測はすでに不可能であるため、「遅く」する必要はありません(計算については「Exploitation d」を参照)。リクエストごとに低速 KDF を使うと、最も負荷の高いコードパスの CPU コストも増大します。この非対称性は逆方向にも当てはまります。高エントロピーのトークンにパスワード用ハッシュ(bcrypt/argon2)が使われている場合は、不要なオーバーヘッドとして記録してください(参考情報であり、指摘事項ではありません)。低エントロピーのトークンに単純なハッシュが使われている場合、そのハッシュは見せかけにすぎず、重大度はエントロピーの指摘事項によって決まります。 - ダイジェストの切り詰め(SHA-256 で 32 バイト未満)およびソルトなしの MD5/SHA-1 系は拒否してください:High。
3. 送信ルール
- TLS のみ:API は、平文 HTTP でのトークンを含むリクエストを拒否する必要があります。エッジ側の証拠では TLS が参照されています(
skills/server/tls-proxy/SKILL.md)。アプリ側では、X-Forwarded-Proto: httpによるバイパスを許すコードパスがないことを確認してください。 - 送信方法:デフォルトで適切なのは
Authorization: Bearerヘッダーです(RFC 6750)。カスタムヘッダー(X-API-Key)も、一貫して適用されていれば問題ありません。クエリ文字列での送信(?access_token=/?api_key=)は重大度 High の指摘事項です。アクセストークンはアクセスログ、ブラウザーの履歴、外部リンクへのアクセス時のRefererヘッダー、中継機器に記録されます。レガシークライアントの都合でクエリ文字列を使わざるを得ない場合は、Remediation に記載のログからの秘匿化対策と、廃止期限を必須としてください。 - Cookie での送信:Cookie に入れたトークンは、ブラウザーの CSRF モデルの影響を受けます(WEB を参照。
skills/code/web-client/SKILL.md:SameSite、CSRF トークン、ワイルドカードドメインの Cookie を使わない)。マシン向け API では通常、設計上の問題を示すものです。ブラウザーフローで本当に必要な場合を除き、CSRF の相互参照を付けて Medium として報告してください。
4. スコープと権限モデル
- 発行済みトークンを一覧化します
[APP-DB]:SELECT client_id, count(*), array_agg(DISTINCT scopes) FROM api_tokens GROUP BY 1;すべてのインテグレーションで同じ1つの行を共有している場合は、神のトークンパターンに該当するため、High とします。 - 必須のモデル:明示的なスコープ(例:
read:invoices、write:invoices)を持つクライアントごとのキー、テナント/リソースとの紐付け(テナント所有のオブジェクトではtenant_idを NOT NULL にする)、機密性の高い領域では読み取り用と書き込み用の認証情報を分離します。 - スコープはミドルウェアで強制し、慣例に任せないでください。署名もサーバー側の権限行も確認せずに、トークンの内容からスコープ/テナントのクレームを解析するコードがあれば指摘してください。検証されていない自己申告のスコープは、トークンの偽造による権限昇格につながるため、High とします(書き込み/管理者スコープをクレームで取得できる場合は Critical)。
- 管理者エンドポイントに個別のスコープが必須であり、既存のトークンにそのスコープを「一時的に」付与したものがないことを確認してください。
5. 有効期限とローテーション
- 有効期間のポリシー:
expires_atを設定し、期限を有限にすることを推奨します。長期間有効なトークンを許容するのは、補完的な対策が文書化されている場合に限ります。具体的には、定期的なローテーションポリシー、利用状況の監視、即時失効機能の3つすべてが必要です。いずれかが欠けている場合は High とします。 - 実態を測定します
[APP-DB]:
SELECT count(*) FILTER (WHERE expires_at IS NULL) AS immortal_tokens, count(*) AS total FROM api_tokens;
SELECT max(last_used_at) AS stale_active FROM api_tokens WHERE revoked_at IS NULL;
- 使用時に
last_used_atを更新する必要があります(書き込みの頻度を抑えても問題ありません。例:トークンごとに最大1分に1回)。利用状況を追跡していない場合は Medium とします(侵害の検知と古いキーの管理ができなくなります)。 - 古いトークンの整理ポリシー:90日を超えて未使用の場合は無効化し、保持期間後に削除します。期間を文書化し、ジョブまたはランブックの有無を確認してください。
- ローテーションの仕組みでは、連携先がダウンタイムなしで切り替えられるよう、有効期間を重複させる必要があります(両方のトークンを同時に有効にします)。切り替えと同時に変更して祈るしかない場合は、プロセス上の指摘事項として Medium とします。過去のローテーションによる本番環境の障害があれば、裏付けとなります。
6. 失効とインシデント対応手順書
- 失効手段が存在し、利用可能であることを証明します。管理者 UI、API、または文書化された運用手順が対象です。手段がまったくない場合は「高」とします。
- 認証のホットパスをたどり、キャッシュ(プロセス内のマップ、Redis、CDN/認証サービスのキャッシュ)を確認します。各レイヤーについて、キーの形式、TTL の値、失効操作によってキャッシュが削除または更新されるかを記録します。無効化されないキャッシュは「高」の指摘事項です。失効したトークンが TTL の期限まで使い続けられるため、コード内の
ttl_secondsからその時間を算出し、指摘事項に明記します。 - 情報漏えい対応のチェックリスト(手順書の有無と訓練の証跡):
- トークンを失効させます(明示された制限時間内に失効が反映されることを確認します)。
- 使用状況を調査します。トークンのプレフィックスと ID でログを検索し、漏えい後のアクティビティを確認して、時系列を特定します。
- 依存する連携をローテーションします。重複利用可能な期間を設け、同じスコープで代替トークンを発行します。
- 影響範囲を評価します。スコープ、テナントの紐付け、
last_used_atの差分をもとに、通知義務の有無を判断します。 - トークンの所有者/顧客に、事実に基づく対象期間と実施した対応を通知します。
- 事後検証を行います。どのように漏えいしたか(ログ、リポジトリ、サポートチケットなど)、どの管理策が機能しなかったかを確認します。
- アラート対象として有用な侵害の兆候(ロギング・監視モジュールに組み込みます):1 つのトークンで API の送信元 IP があり得ない移動を示す、401 が急増した後に 200 が続く(推測が成功した)、既知のクライアント ID で見慣れない User-Agent または ASN が使われる、リクエスト量が急変する。
7. キーごとのレート制限と不正利用対策
- 認証済みのすべてのリクエストはキーに帰属でき、すべてのキーにはクォータが必要です。バースト制限(リトライを吸収するトークンバケット)と持続的な制限(分あたりおよび/または日あたりのリクエスト数)を設定します。設定がない場合は「中」、書き込み/管理者エンドポイントで設定がない場合は「高」とします。
- 動作:上限を超えた場合は
Retry-After(秒)とともに429を返します。コード/ドキュメントでヘッダーを確認してください。接続を黙って切断したり、500 を返したりする場合は指摘事項です。 - 未認証のエンドポイント(ログイン、トークン発行、サインアップ)には、キーごとの制限とは別に、IP+識別子をキーとする、より厳しい独自のバケットが必要です(API の
skills/code/api-security/SKILL.mdおよび DoS のskills/code/denial-of-service/SKILL.mdを参照)。 - エッジでの制限(nginx
limit_req)は補完策として利用できますが、クライアントごとに安定した情報をキーにする必要があります。$http_authorizationの注意点については「修正」を参照してください。
8. キー列挙への耐性
- 拒否の動作を統一します。無効なトークン、形式不正のトークン、不明なキー、期限切れ、失効済みのいずれも、同一のステータス(401)、同一の本文形式、見分けのつかない応答時間にします。「期限切れ」と「不明」を明かすエラー分類は正当なデバッグには役立ちますが、攻撃者に列挙の手掛かりを与えます。一般的なメッセージと、詳細を分けた内部ログを必須とし、これに違反した場合は「中」とします。
- 定時間比較:提示されたトークンのダイジェストと保存済みのダイジェストを比較する最終チェックには、
crypto.timingSafeEqual(Node)/hmac.compare_digest(Python)または同等の関数を使う必要があります。**悪用可能性についての正確な注記:**比較対象はハッシュです。攻撃者の入力から生成されるダイジェストは一様にランダムなため、memcmp の早期終了によって、攻撃者が狙って一致させることのできない値に対し、先頭から何バイト一致したかが漏れるだけです。ネットワークジッターを介したリモートからの悪用は、実際には現実的ではありません。それでも必須とします。関数呼び出し 1 回分のコストで、監査指摘事項の分類を 1 つ丸ごとなくし、将来のリファクタリング(たとえば、誰かがプレフィックス一致のショートカットを追加する場合)にも備えられます。定時間でない比較は、ほかに何を比較するかに応じて「低」から「中」とします(生のプレフィックスやユーザーに見える識別子を比較する場合は、深刻度が上がります)。 - 大まかな確認(本番環境に大量のリクエストを送らないこと):形式を固定した無効なトークンを使って 20 件のリクエストを計測し、ばらつきを目視します。方法と注意事項については「検証」を参照してください。
9. トークン使用状況のログ記録と監視
- トークン全体を記録しないルール:アクセスログ、アプリログ、エラートラッカー、APM のペイロードに、生のトークンを含めてはいけません。安全なフィールド:トークンのプレフィックス(先頭約 12 文字)、トークンのデータベース ID、クライアント ID、認証済みサブジェクト、送信元 IP、UA、ルート、レイテンシ。
- 実際のログを検索します(「確認すべき場所」ブロックを参照):
Bearer [A-Za-z0-9_.=-]{20,}またはaccess_token=のヒットは重大な所見です。ログの閲覧者(運用担当者、SIEM、ログ転送ツール、バックアップ)は、データベースの閲覧者よりはるかに多いためです。 - アラートフック(logging-monitoring モジュールを相互参照):キーごとの 401 レートの異常、クォータの飽和、スコープ拒否の急増(
403の急増は、侵害されたトークンが境界を探っている兆候です)、クライアント ID ごとの新しい UA/新しい地域。
10. 配布するトークンのクライアント側での衛生管理
- 公開ドキュメントやクイックスタートでは、環境変数からの読み込みを示し、インラインのリテラルは決して使用しないでください。もっともらしいトークンを含むサンプルコードは、偽物であっても検出対象になります。ユーザーがそのままリポジトリにコピーするためです(SECRETS
skills/code/secrets-data-exposure/SKILL.mdを相互参照)。 - Webhook 署名シークレットにも、トークンと同じライフサイクルを適用します。CSPRNG による生成、保存時のハッシュ化、プレフィックスによる識別、二重受け入れによるローテーション、定時間 HMAC 検証です。ここでは概要のみを示し、詳細は SECRETS を参照してください。
- 配布チャネル:トークンを表示するのは作成時の 1 回のみです。サポートチケットやメールに有効なトークンが含まれている場合は重大な所見です(サードパーティーのメールストアに保存されるため)。
11. ホスト上に潜むトークンの場所(HSECRET への橋渡し)
発行済みのトークンは、トークンサービスの外部に蓄積されがちです。
- アプリの設定ファイル:
.env、application.yml、settings.pyの定数、/opt/<app>配下の JSON 設定ファイル。権限(600、サービスユーザー所有)と内容を確認します。 - systemd ユニットファイル:
Environment=行にシークレットを埋め込むと、誰でも読み取り可能なユニットファイルに漏れることがあります。通常のインストールでは、ユニットファイル自体やsystemctl show <unit> -p Environmentを通じて、ローカルユーザーなら誰でも読み取れます(ユニットのプロパティは通常、ファイルの DAC による制限を受けません)。/proc/<pid>/environは所有ユーザー(または root)のみに制限されているため、露出範囲は異なります。ユニットファイルのEnvironment=はすべてのローカルユーザーに漏れますが、プロセス環境変数は同じ UID のユーザーまたは root にのみ漏れます。600で root 所有またはサービスユーザー所有のファイルを指定するEnvironmentFile=、またはシークレットマネージャーを優先してください。 - シェル履歴:
export API_TOKEN=...を実行した運用担当者の認証情報は~/.bash_historyに残ります。ホストの調査では root とサービスユーザーの履歴を確認してください。 - 詳細(ダンプの探索、CI アーティファクト、イメージレイヤー)→ HSECRET
skills/code/secrets-data-exposure/SKILL.md。
確認すべき場所
証拠の収集:tools/sweeps/sweep-api-tokens.sh は [TOK-nn] セクションをそのまま取得します。それらを生の出力だけで判断せず、このモジュールの評価基準に照らして評価してください。
| 証拠 | 場所 | アクセス |
|---|---|---|
| トークン発行+認証ミドルウェアのソース | repo:auth/、middleware/、security/ ディレクトリ。Bearer、X-API-Key を検索 | 読み取り |
| スキーマの実態 | アプリケーション DB の information_schema.columns(マイグレーションではなく、こちらを参照してください。マイグレーションは実態とずれることがあります) [APP-DB] | 読み取り用認証情報 |
| トークンの行(メタデータのみ) | api_tokens 相当のテーブル:プレフィックス、スコープ、有効期限、最終使用日時 [APP-DB] | 読み取り用認証情報 |
| エッジ設定 | /etc/nginx/nginx.conf、/etc/nginx/conf.d/*.conf、Caddy/HAProxy 相当の設定 | 読み取り |
| サービス環境変数 | /etc/systemd/system/*.service(Environment=、EnvironmentFile=)、systemctl show <unit> -p Environment | root のユニットに対する show には [ROOT] が必要 |
| アプリ設定 | /opt、/srv、アプリのホームディレクトリ配下の .env*、application.yml、settings.py、config/*.json | 読み取り |
| プロセス環境変数(同一ユーザー) | /proc/<app-pid>/environ | 同じ UID または [ROOT] |
| ログ | /var/log/nginx/access.log*、アプリのログディレクトリ、サービスの journalctl | 読み取り [ROOT] |
| シェル履歴 | /root/.bash_history、/home/*/.bash_history | 読み取り [ROOT] |
そのまま貼り付けて使える読み取り専用の調査(約 15 行):
# ===== [READ-ONLY SWEEP] token-bearing configs, env, logs on one host =====
sudo grep -RInE '(api[-_]?(key|token)|bearer|access[-_]?token|auth[-_]?token|secret)[-=_ ]+["'"'"']?[A-Za-z0-9_.+-]{16,}' \
/etc/systemd/system /opt /srv /var/www 2>/dev/null | head -40
sudo grep -RHnE '^Environment=.*(TOKEN|KEY|SECRET|PASSWORD)' /etc/systemd/system 2>/dev/null | head -20
sudo find /opt /srv /etc /home -maxdepth 5 \( -name '.env' -o -name '.env.*' \) -exec stat -c '%a %U:%G %n' {} + 2>/dev/null
sudo ls -la /root/.bash_history /home/*/.bash_history 2>/dev/null
sudo grep -RhoE '(API_TOKEN|API_KEY|SECRET)[A-Z_]*=\S{8,}' /root/.bash_history /home/*/.bash_history 2>/dev/null | head
# --- full tokens hitting disk in logs? (counts per file) ---
sudo grep -REo 'Bearer [A-Za-z0-9_.=-]{20,}' /var/log/nginx /var/log/apache2 /var/log/apps 2>/dev/null | cut -d: -f1 | sort | uniq -c | head
sudo zgrep -hcoE '[?&](access_token|api_key|apikey|token)=[A-Za-z0-9_-]{8,}' /var/log/nginx/access.log* 2>/dev/null | paste -sd+ | bc
# --- DB schema introspection [REQUIRES APP-DB READ CREDS - request from owner; skip if unavailable] ---
# psql "$APP_DB_URL" -c "SELECT table_name,column_name,data_type FROM information_schema.columns WHERE column_name ~* '(token|api_key|secret)';"
# mysql -u"$DBUSER" -p"$DBPASS" -e "SELECT TABLE_NAME,COLUMN_NAME FROM information_schema.COLUMNS WHERE COLUMN_NAME REGEXP 'token|api_key|secret';"
パターンとシグネチャ
標準ストレージスキーマ
-- FIXED: authoritative API-token table (PostgreSQL dialect)
CREATE TABLE api_tokens (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
client_id BIGINT NOT NULL REFERENCES clients(id),
token_prefix VARCHAR(15) NOT NULL, -- e.g. 'sk_live_Xk29fQ' : display/log-safe identifier
token_hash BYTEA NOT NULL, -- SHA-256(raw token); raw value is NEVER persisted
scopes TEXT[] NOT NULL DEFAULT '{}',-- e.g. '{read:invoices,write:invoices}'
tenant_id BIGINT REFERENCES tenants(id), -- NOT NULL wherever objects are tenant-owned
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ, -- NULL only with documented compensating controls
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
rotated_from_id BIGINT REFERENCES api_tokens(id)
);
CREATE UNIQUE INDEX ux_api_tokens_token_hash ON api_tokens (token_hash); -- O(1) lookup by digest
CREATE INDEX ix_api_tokens_client ON api_tokens (client_id);
CREATE INDEX ix_api_tokens_prefix ON api_tokens (token_prefix);
プレフィックス列を使う方法:一意インデックスは token_hash に設定されるため、検証は厳密かつ衝突のない状態を保てます。token_prefix は人間が確認するためだけに存在します。サポートチケット(「sk_live_Xk29… で始まるキー」)、ログの相関付け、失効対象の調査などに使えますが、認証に十分な情報を保存することはありません。
トークンの発行
// FIXED: 256-bit CSPRNG body + type prefix + integrity checksum (Stripe-style shape)
const crypto = require('crypto');
function mintToken(env /* 'live' | 'test' */) {
const body = crypto.randomBytes(32).toString('base64url'); // 256 bits
const raw = `sk_${env}_${body}`;
const checksum = crypto.createHash('sha256').update(raw).digest('base64url').slice(0, 8);
return { raw: `${raw}_${checksum}`, prefix: raw.slice(0, 12) }; // return raw ONCE; persist hash+prefix
}
-- VULNERABLE: any of these shapes in a mint/storage path is a finding
-- CREATE TABLE api_tokens (id ..., token TEXT NOT NULL, client_id ...); -- plaintext column
-- token = md5(user_id || timestamp) -- derivable base
-- SET token = SUBSTRING(MD5(raw), 1, 16) -- truncated digest
認証ミドルウェア — Node/Express
// VULNERABLE: plaintext lookup, == compare, immortal cache, no scope check
const tok = await db.query('SELECT * FROM api_tokens WHERE token = $1',
[req.headers.authorization.slice(7)]);
if (tok && tok.rows[0].token === req.headers.authorization.slice(7)) { req.client = tok.rows[0]; next(); }
// FIXED: hash-lookup flow + constant-time verify + revocation-aware cache
const crypto = require('crypto');
const authCache = new Map(); // digestHex -> {row|null, validUntil}
const CACHE_TTL_MS = 60_000; // bounds revocation lag at <=60 s
async function authenticate(req, res, next) {
const m = /^Bearer ([A-Za-z0-9_-]{40,})$/.exec(req.get('authorization') || '');
if (!m) return res.status(401).set('WWW-Authenticate', 'Bearer').end();
const presented = m[1];
const digest = crypto.createHash('sha256').update(presented).digest(); // Buffer(32)
let entry = authCache.get(digest.toString('hex'));
if (!entry || entry.validUntil < Date.now()) {
const r = await db.query(
'SELECT * FROM api_tokens WHERE token_hash = $1 AND revoked_at IS NULL', [digest]);
entry = { row: r.rowCount ? r.rows[0] : null, validUntil: Date.now() + CACHE_TTL_MS };
authCache.set(digest.toString('hex'), entry);
}
const tok = entry.row;
if (!tok || tok.revoked_at !== null ||
(tok.expires_at && new Date(tok.expires_at) < new Date())) {
return res.status(401).set('WWW-Authenticate', 'Bearer error="invalid_token"').end();
}
if (!crypto.timingSafeEqual(digest, Buffer.from(tok.token_hash))) { // constant-time end-to-end
return res.status(401).end();
}
req.auth = { tokenId: tok.id, clientId: tok.client_id, scopes: tok.scopes, tenantId: tok.tenant_id };
next();
}
function requireScope(s) {
return (req, res, next) =>
(req.auth && req.auth.scopes.includes(s)) ? next() : res.status(403).json({ error: 'insufficient_scope' });
}
// On revoke: authCache.delete(sha256Hex(revokedToken)); redis.publish('token_events', JSON.stringify({op:'revoke', id}));
認証依存関係 — Python/FastAPI(Django DRF でも、BaseAuthentication.authenticate() が (user, auth) を返す同じフローです)
# VULNERABLE: raw-value ORM filter + != None checks leaking state via exception type
def get_client(request):
raw = request.query_params.get("token", "") # query-string carriage too
t = ApiToken.objects.filter(value=raw).first() # plaintext column
if t is None:
raise NotAuthenticated("unknown token") # distinct error = enumeration oracle
return t
# FIXED: FastAPI dependency — hash lookup, constant-time compare, bounded cache
import hashlib, hmac, time
from fastapi import Request, HTTPException, status
_CACHE: dict[str, tuple[object, float]] = {}
_TTL = 60.0 # seconds; bounds revocation lag
async def bearer_auth(request: Request) -> AuthContext:
authz = request.headers.get("authorization", "")
if not authz.startswith("Bearer ") or len(authz) < 40:
raise HTTPException(status.HTTP_401_UNAUTHORIZED,
headers={"WWW-Authenticate": 'Bearer error="invalid_token"'})
presented = authz[7:].encode()
digest = hashlib.sha256(presented).digest()
now = time.monotonic()
hit = _CACHE.get(digest.hex())
if hit is None or hit[1] < now:
row = await db.fetch_row(
"SELECT * FROM api_tokens WHERE token_hash = $1 AND revoked_at IS NULL", digest)
hit = (row, now + _TTL)
_CACHE[digest.hex()] = hit
tok = hit[0]
if tok is None or tok["revoked_at"] is not None or (
tok["expires_at"] is not None and tok["expires_at"] <= now_utc()):
raise HTTPException(status.HTTP_401_UNAUTHORIZED) # identical body/timing shape for all causes
if not hmac.compare_digest(digest, bytes(tok["token_hash"])):
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
return AuthContext(token_id=tok["id"], client_id=tok["client_id"],
scopes=tok["scopes"], tenant_id=tok["tenant_id"])
コード署名のクイック表(リポジトリ内の grep 用)
| 署名 | 意味 |
|---|---|
req.query.token、request.query_params["api_key"]、parse_str($_GET['access_token']) | クエリ文字列での受け渡しが見つかった箇所 |
_hash ではない列に対する WHERE token = ? | 平文での保存経路 |
token === provided、== raw、文字列に対する .equals( | 定時間比較ではない比較 |
トークン/キーの生成箇所付近の Math.random(...) | CSPRNG を使わない発行 |
失効コードに対応する削除処理がない cache.set('auth:' + token, ...) | 失効処理の漏れ |
logger.info(...authorization...)、console.log(headers) | トークン全体のログ出力 |
レガシークライアントによりクエリ文字列でのトークンが避けられない場合のログ秘匿パターン
# MITIGATION (not cure): drop the query string from logged request lines entirely
log_format safe '$remote_addr [$time_local] "$request_method $uri $server_protocol" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent"';
access_log /var/log/nginx/access.log safe;
# App-side belt-and-suspenders: redact before any logger sink
class TokenRedactor(logging.Filter):
_PAT = re.compile(r"(access_token|api_key|apikey)=([A-Za-z0-9_-]{4})[A-Za-z0-9_-]+")
def filter(self, record):
record.msg = self._PAT.sub(r"\1=\2***redacted***", str(record.getMessage()))
return True
汚染追跡ガイダンス
リポジトリ/設定の監査では、トークンをソースからすべてのシンクまで汚染として追跡し、各段階での表現を分類してください。
ソース(システムに生のトークンが入る場所):
req.headers.authorization/request.headers["Authorization"]/request.META["HTTP_AUTHORIZATION"]— 想定されるソースです。X-API-Keyまたはその他のカスタム認証情報ヘッダー。req.query.token、searchParams.get("access_token")、Flask のrequest.args、Django のrequest.GET、PHP の$_GET[...]— ソース自体で見つかった、クエリ文字列での受け渡しです。- API トークンを含む Cookie(WEB CSRF を参照)。
- 運用側:CLI フラグ(
--token=...)、シェル環境変数、ビルドログに出力される CI 変数。
シンク(生のトークンが決して到達してはならない場所):
- 永続化:ORM の
create()/update()、生の INSERT/UPDATE、認証に使える値を*_hash以外の列に書き込むマイグレーションのデフォルト値 → 重大なシンク。 - ログ:ヘッダーを受け取るあらゆるロガー呼び出し、リクエストダンプ(
JSON.stringify(req.headers))、認証コンテキストが付加された例外オブジェクト → 高リスクのシンク。 - レスポンス/テンプレート:エラーやデバッグページで「送信したトークン」をそのまま返すこと → 列挙/機密情報漏えいのシンク。
- 外部への伝播:トークンを含むリダイレクト/分析用 URL の生成、クライアント側コードでトークンを使って初期化されるサードパーティ SDK。
- 設定/リポジトリ:どこかにコミットされたリテラル(SECRETS を参照)。
手順:
- 認証のエントリーポイント(ミドルウェア/依存関係/認証クラス)を見つけます。ここが表現を判断するための信頼できる根拠です。
- トークン変数を前方にたどり、各代入を RAW または DIGEST に分類します。RAW が永続化またはログに到達する箇所が一つでもあれば、問題として報告します。報告にはファイル名と行番号を記載します。
- 発行処理について逆方向に確認します。生成関数の呼び出し箇所を見つけ、CSPRNG と長さを確認し、導出可能な入力にフラグを立てます。
- 経路上の比較処理を確認します。文字列の等価演算子は定義上、定時間ではありません。
- RAW と DIGEST のどちらをキーにしてキャッシュへ書き込んでいるか、また失効時に削除されるかを確認します。
- Webhook 署名シークレットについても、同じライフサイクルのため手順 1~5 を繰り返します。
悪用と再現
すべてのデモは読み取り専用です。発見したトークン値をレポートに貼り付けないでください。プレフィックスと ID を参照してください。
(a)スキーマダンプで平文保存を証明します。
-- [APP-DB] read-only
SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE column_name ~* '(token|api_key)' ORDER BY 1;
-- Finding shape: a column like api_tokens.token TEXT with NO sibling *_hash column,
-- while application code filters WHERE token = <presented value>.
SELECT length(token) AS len, count(*) FROM api_tokens GROUP BY 1 ORDER BY 2 DESC LIMIT 5;
値を読み取らずに解釈します。長さが、ドキュメントに記載された形式と一致する一様な値(たとえば、すべての行が len=51)に集中していれば、実際に発行されたトークンが全体のまま保存されていることを示します。一方、ハッシュのみが保存されているなら、_hash という名前の固定長 bytea/32 バイトの列が確認できるはずです。レポートには次のように記述します。攻撃者が何らかの DB 読み取り手段(SQLi、バックアップの露出、レプリカの設定ミス)を得ると、1 回のクエリですべての顧客の有効な認証情報を入手できます。ハッシュ化の手順がないため、クラックの手順も不要です。これが、この項目の重大度を「クリティカル」とする根拠です。
(b)アクセスログの検索でクエリ文字列経由の送信を証明します。
sudo zgrep -hcE '[?&](access_token|api_key|apikey|token)=[A-Za-z0-9_-]{8,}' /var/log/nginx/access.log* | paste -sd+ | bc
sudo zgrep -hoE '^[^ ]+ [^ ]*\?[a-z_]*token=[A-Za-z0-9_-]{6}' /var/log/nginx/access.log | sort | uniq -c | head # prefix only!
件数が 0 より大きければ、それが発見事項です。件数と日付範囲(「30 日間で、1,204 件のリクエストに URL 内の認証情報が含まれていました」など)に加えて、二次的な影響範囲も報告してください。同じ値がログ転送パイプライン、SIEM の保持データ、/var/log のバックアップにも残ります。ブラウザー(curl クライアントではなく)がそのような URL を使用する場合は、Referer 経由の漏えいによって影響がさらに大きくなります。
(c)キャッシュのコードパスを確認して失効のギャップを証明します。
ミドルウェアを読み取ります(「パターン」セクションに両方の形態が記載されています)。キャッシュ層、キー、TTL 定数、失効ハンドラーに削除処理があるかどうかを記録します。Redis を使用している場合は、ホスト上で読み取り専用コマンドを実行して裏付けを取ります。
redis-cli --scan --pattern '*auth*' | head # key naming shows token-derived keys exist
redis-cli TTL "$(redis-cli --scan --pattern '*auth*' | head -1)" # remaining validity of a cached entry
レポートには次のように記述します。運用担当者が T+0 に盗まれたトークンを失効させても、攻撃者は TTL が経過するまで完全なアクセス権を維持します(実測値を「3600 s」のように明記します)。アプリがプロセス内キャッシュを持つ N 個のレプリカで稼働している場合、失効が伝播する速度は各レプリカの TTL に制限されるため、それに応じて猶予時間が長くなります。これにより、「失効させました」(インシデント対応完了)が「失効の遅延は数時間です」(重大度「高」の発見事項)に変わります。
(d)計算を含む総当たり攻撃の説明。
API に対するオンライン推測攻撃に足がかりは不要です。必要なのは、根気とレート制限の欠如だけです。すべてはキー空間で決まります。
8-char alphanumeric token (62-symbol alphabet):
keyspace = 62^8 = 218,340,105,858,848 ≈ 2.18e14 ≈ 2^47.6
offline : sha256 @ ~1e10 H/s (one GPU rig) -> 2.18e14 / 1e10 ≈ 21,834 s ≈ 6 h DEAD
online : @ 1,000 req/s, unthrottled -> ≈ 6,927 years alive-but-noisy
verdict : fails the 128-bit floor by a factor of ~2^80; dead the moment any hash
database leaks, and trivially scannable even in plaintext dumps.
UUIDv4 token:
keyspace = 2^122 ≈ 5.3e36
offline @ 1e10 H/s -> ≈ 1.7e19 years -> entropy is fine; borderline flag is about format/handling.
32-byte CSPRNG token (the floor case):
keyspace >= 2^128 ≈ 3.4e38; @ 1e12 H/s -> 3.4e26 s ≈ 1.1e19 years ALIVE
レポートのテンプレート:「トークンのエントロピーは約 47.6 ビットで、128 ビットの下限を下回っています。キーごとのスロットリングがないこと(チェック 7)と合わせると、現実的な速度でオンライン推測を継続できます。いずれかのストアが漏えいした場合、オフラインでの復元にはトークン 1 つあたり約 6 GPU 時間かかります。」その後、レート制限だけでなく設計を修正してください。
修正
移行計画:平文トークンからハッシュ化へ(ダウンタイムなし)
- 列を追加します(
token_hash BYTEA、token_prefix VARCHAR(15)、および不足している場合はexpires_at、last_used_at、revoked_at)。この時点では一意インデックスを追加しません。 - デュアルチェック認証(v1)をデプロイします。 ミドルウェアはまず、提示されたトークンの
SHA-256をtoken_hashと照合します。一致しない場合は、従来の平文列にフォールバックします。従来の列で一致した場合は、直ちにtoken_hashを書き込み、平文列を空にします(使用時に段階的にバックフィルするため、アクティブなキーの大半は数時間以内に移行します)。 - アイドル状態のキーを対象としたバックフィルジョブを実行します。ロックの集中を避けるため、バッチ処理にします。
-- run in batches of ~10k until 0 rows updated
UPDATE api_tokens
SET token_hash = sha256(convert_to(token, 'UTF8')), token_prefix = left(token, 12), token = ''
WHERE token <> '' AND token_hash IS NULL;
- 検証します。
SELECT count(*) FROM api_tokens WHERE token <> '' OR token_hash IS NULL;の結果が 0 である必要があります。そうでない場合は、手順 3 を再実行します。 - ハッシュのみの認証(v2)をデプロイします。 ミドルウェアから平文へのフォールバック経路を削除します。
- メンテナンス時間帯に平文列を削除します。 移行に対応できないトークン(慎重なクライアント向け)は、以下のローテーション手順に従ってローテーションします。
ローテーション手順書(ダウンタイムなしの重複期間)
- 同一のスコープとテナント紐付けで新しいトークンを発行し、
rotated_from_idを記録します。 - クライアントに帯域外で配布します(メールやチケットでは決して送信しないでください)。表示は一度だけにします。
- 重複期間(例:7日間)を設けます。古いトークンと新しいトークンの両方が検証に成功します。実装方法:古いトークンはまだ失効させないだけです。両方の行で個別に認証されます。
old.last_used_atを監視し、期間の終了が近づいたら、まだ古いキーを使用している連携先に連絡します。- 期間の終了時に古いトークンを失効させます(
revoked_at = now())。監査用に行は保持し、すでに連絡済みの未対応先に対してのみ 401 が発生していることを確認します。 - 保持期間の経過後、ポリシーに従って失効済み/期限切れの行を削除します。
運用上、最も注意すべきミスは、新しいトークンが有効になったことを確認する前に古いトークンを失効させることです。これが「ローテーション」を障害に変えてしまいます。重複期間を設けるのは、まさにそれを防ぐためです。
レート制限の設定形式
// FIXED: express-rate-limit — IP gate BEFORE auth, per-key quota AFTER auth
const { rateLimit } = require('express-rate-limit');
const ipLimiter = rateLimit({ windowMs: 60_000, max: 20, standardHeaders: 'draft-7' }); // unauthenticated surface
app.use('/api', ipLimiter);
app.use(authenticate);
const keyLimiter = rateLimit({
windowMs: 60_000,
max: (req) => req.auth.quotaPerMin ?? 600, // sustained quota per key
keyGenerator: (req) => String(req.auth.tokenId), // stable per-client identity, NOT the raw token
standardHeaders: 'draft-7',
});
app.use('/api', keyLimiter);
# Edge complement — honest caveat: limit_req keyed on $http_authorization works mechanically,
# but nginx then stores RAW tokens as shared-memory keys (readable via debug/core dumps) and the
# key changes if clients vary header casing/padding. Prefer app-layer limiting keyed on token id;
# use the edge zone only as a coarse backstop, and always pair with an IP zone for junk traffic.
limit_req_zone $http_authorization zone=perkey_backstop:10m rate=600r/m;
limit_req_zone $binary_remote_addr zone=perip:10m rate=30r/m;
limit_req zone=perip burst=20 nodelay;
適用する動作:上限超過時は Retry-After: <seconds> を付けて 429 を返します。バースト(トークンバケット)は正当な再試行を吸収できるサイズにし、継続的な上限でバックエンドを保護します。未認証エンドポイントには、それぞれ専用のより厳しいバケットを設定します(API を相互参照)。
失効キャッシュの無効化パターン
どちらかを選び、設計ドキュメントにトレードオフを記載します。
- イベントによる無効化(推奨): 失効処理でローカルキャッシュを削除すると同時に、Redis pub/sub/Postgres
LISTEN-NOTIFY/キューにtoken_events {op:'revoke', id}を発行します。各ワーカーは受信時にキャッシュを破棄します。失効の遅延は約ミリ秒単位で、代償はサブスクリプションの配線です。 - 短い TTL による制限(許容できる代替策): キャッシュ TTL を 60 秒以下にハードコードし、ドキュメントに記載します。トレードオフを明確に記載します。失効したトークンは最大 60 秒間有効なままです。これは攻撃者が利用できる期間が短く、上限が定まっているため許容できる場合に限ります。認証判定を長い TTL でキャッシュする CDN とは決して併用しないでください。
どちらの場合も、失効コードの経路では Check 6 で特定したすべてのキャッシュ層に対応する必要があります。インシデント発生時に、その一覧を書き残してください。
検証と妥当性確認
修正後の検証項目:
- スキーマに UNIQUE インデックス付きの
*_hash(BYTEA、32 バイト)があり、認証可能な値を格納する列が残っていないことを確認します。Check 2 のイントロスペクションクエリを再実行します。 - ログにはプレフィックスのみが含まれていることを確認します。ブロックの Bearer 正規表現による検索で、新しいトラフィックからの一致が 0 件であることを確認し、アプリログでヘッダーのダンプをサンプル確認します。
- 認証フロー:有効なトークンで認証に成功する(肯定テスト)。形式は正しいが誤ったトークンでは、期限切れ/失効済みの応答と本文/サイズが同一の一律な 401 が返される(否定テスト)。失効済みのトークンでは、ドキュメントに記載された上限時間内に 401 が返される(テスト用トークンを失効させ、そのトークンでポーリングし、遅延を測定します)。
- おおまかなタイミングの均一性テスト(注意点あり):
for i in $(seq 1 30); do curl -s -o /dev/null -w '%{time_total}\n' \
-H 'Authorization: Bearer AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' https://api.example.com/v1/me; done \
| sort -n | sed -n '1p;$p'
有効なトークンを使った同じループの結果と、最小値/最大値のばらつきを比較します。結果は正直に評価してください。ネットワークの揺らぎはナノ秒単位の memcmp の差を大きく上回ります。このテストで検出できるのは、DB ミスと DB ヒットの経路で数十ミリ秒の差が出るなど、顕著なオラクルであり、微妙な差ではありません。顕著なばらつきがなければ合格とし、微妙な差は不合格にせず、記録します。
- スコープに関する否定テスト:読み取り専用トークンで書き込みルートにアクセスすると 404/500 ではなく 403 が返ること。テナント A のトークンでテナント B のリソースを要求すると、一貫して 403/404 が返ること。
- リグレッションの記録:キーごとの制限を有効にした後、正当なバッチジョブで 429 が頻発していないことを確認します(
Retry-Afterの遵守状況を監視)。TTL/キャッシュの変更後は、認証の p95 レイテンシが変わっていないことを確認します。
CI/設定リポジトリでの grep(一致があればビルドを失敗させます):
! git grep -InE "(Math\.random|random\.random)\(\).*([Tt]oken|[Kk]ey)" # non-CSPRNG minting
! git grep -InE "req\.(query|query_params)\.(get\()?['\"](access_token|api_key|token)['\"]"
! git grep -InE "(authorization|Bearer).*(logger\.|console\.|print\()" # full-token logging
git grep -qIn "timingSafeEqual\|compare_digest" -- server/ src/ # constant-time compare present
深刻度評価
| 検出事項 | 基準 | CVSS v3.1 ベクトル | スコア/注記 |
|---|---|---|---|
| インターネット向け API を提供する DB 内の平文/可逆トークン | Critical | AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H | 9.8。PR:N は、DB 読み取りプリミティブ(SQLi、漏えいしたバックアップ)との連鎖を前提とします。監査で立証されるまでは存在するものとして扱ってください。本当に読み取りプリミティブが存在しない場合は、正直に AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N = 6.8 に再評価し、検出事項を引き続き明示してください |
| 有効期限がなく、失効手段もない(またはキャッシュ無効化によって失効の遅延を制限できない) | High | AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N | 8.1。単一の漏えいでも永久に有効です。PR:L は、長期間有効なキーを 1 つ所持する者を想定しています |
| 完全なトークンがログ(アクセスログ/アプリケーションログ/SIEM)に書き込まれる | High | AV:A/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N | 7.3。ログ基盤の閲覧者は隣接ネットワーク上にいるモデルです |
| インターネット向けエンドポイントでのクエリ文字列によるトークンの送信 | High | AV:A/AC:L/PR:N/UI:R/S:U/C:H/I:L/A:N | 7.0。ログ/Referer/履歴を介した受動的な傍受 |
| エントロピーの低いトークン生成(<128 bits)または非 CSPRNG による乱数生成 | High | 文脈に基づいて評価:オフラインで復元可能 → ストレージの Critical な問題との連鎖として評価、オンラインで推測可能かつスロットリングなし → AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N | スロットリングがない場合は 8.1 |
| 複数の連携で共有される万能トークン/スコープ制限なし | High | 権限範囲の広さに関する定性的なギャップ。管理者スコープが同じキーに紐付く場合に最も近い基準は AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N = 8.1 | 権限範囲の広さを明示して報告してください |
| キーごとのレート制限なし/429 のセマンティクスの欠如 | Medium | AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N | 4.8 |
| 最終使用日時の追跡なし/古いキーの管理不備 | Medium | 定性的評価。検知コントロールのギャップであり、CVSS では露出期間の延長を表現できません | ポリシー上は Medium。ほかのすべての不備の潜伏期間を、気付かれないまま延長します |
| uuid4 のみのトークン、トークン内の導出可能なメタデータ | Borderline/Low-Med | エントロピーの計算で判断します(122 bits ならブルートフォースに耐えますが、形式/取り扱い上の懸念は残ります) | 指摘はしても、Critical と騒ぎ立てないでください |
| トークン形式に型プレフィックス/チェックサムがない | Low | 定性的評価。運用上の識別/スキャン容易性のギャップであり、機密性への直接的な影響はありません | ポリシー上は Low |
よくある誤検知
- トークンを置き換える mTLS 証明書認証。 クライアント証明書は別の認証モデルです。トークンのハッシュ化/プレフィックスを要求しないでください。ただし、同等のライフサイクルについては確認してください。侵害時に信頼を実際に短縮する CRL/OCSP の手段、証明書ローテーションの手順書、スコープへの CN/SAN のマッピングが該当します。実行時に CRL/OCSP に到達できない場合、それが実際の検出事項です。
- VPN/メッシュの背後にある内部専用 API。 トークンの不備自体は残りますが、露出状況に依存する基準(トランスポート、列挙、レート制限)については深刻度を下げます。ネットワーク境界を補完的なコントロールとして記録し、ストレージに関する検出事項の引き下げは 1 段階までにしてください。内部関係者やバックアップが存在するため、平文保存は少なくとも High のままです。
- 不透明なセッション Cookie を API トークンと誤認。 ブラウザーのセッション Cookie(
connect.sid、sessionid、フレームワークのデフォルト値)は Web セッションモデルに属します。ここではなく、AUTHN/WEB(skills/code/authn-session/SKILL.md、skills/code/web-client/SKILL.md)で監査してください。ランダムな Cookie ID をキーとするサーバー側セッションストアに対して、「トークンが平文で保存されている」と報告しないでください。 - JWT を不透明なトークンと誤認。 「API トークン」が構造上 JWT(ドットで区切られた 3 つのセグメント)である場合、そのリスクは署名/検証に関するもので、AUTHN(
skills/code/authn-session/SKILL.md)で監査します。このモジュールから引き継ぐのは、トランスポート/スコープ制限/ログ記録のチェックのみです。 - 保存時に暗号化されたトークン列。 可逆暗号化はリスク軽減策の主張であり、問題がないことの証明ではありません。High 未満への引き下げを認める前に、鍵の管理体制を確認してください(Check 2 の判定ルールを参照)。
参考資料
- RFC 6750 — OAuth 2.0 認可フレームワーク:ベアラートークンの使用(転送ルール、
WWW-Authenticateのエラーセマンティクス):https://www.rfc-editor.org/rfc/rfc6750 - NIST SP 800-63B — デジタル ID ガイドライン:認証とライフサイクル管理(認証器のエントロピー、保存、検証者の要件):https://pages.nist.gov/800-63-3/sp800-63b.html
- OWASP 認証チートシート:https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- OWASP REST セキュリティチートシート(トークンの運搬、TLS のみの使用、認証判定のキャッシュ):https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html
- OWASP シークレット管理チートシート(配布、ローテーション、Webhook 署名シークレット):https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
- OWASP API セキュリティプロジェクト(A02 認証の不備に関する分類の背景):https://owasp.org/www-project-api-security/
- CWE-522 保護が不十分な認証情報:https://cwe.mitre.org/data/definitions/522.html
- CWE-307 過剰な認証試行に対する制限が不適切:https://cwe.mitre.org/data/definitions/307.html
内部相互参照:JWT/セッションの詳細 → skills/code/authn-session/SKILL.md;Cookie で運搬される認証情報に起因する CSRF → skills/code/web-client/SKILL.md;API 全体の不正利用対策 → skills/code/api-security/SKILL.md;ホスト側でのシークレットの拡散 → skills/code/secrets-data-exposure/SKILL.md;エッジでの TLS 強制 → skills/server/tls-proxy/SKILL.md;大量トラフィックのしきい値 → skills/code/denial-of-service/SKILL.md。