mcpField
S
sast-idor

sast-idor

github.com/utkusenセキュリティ1.3k69 回インストール5 日前に更新
リポジトリ

sast-idor について

sast-idorは、コード内のIDOR脆弱性を調べるセキュリティ評価スキルです。事前にアーキテクチャ概要を用意し、エンドポイントの調査、認可のバッチ検証、結果の統合を進めます。

主な機能

  1. 013段階のIDOR評価
  2. 02候補エンドポイントの発見
  3. 03認可のバッチ検証
  4. 04評価結果の統合

活用シーン

  1. 01リクエストIDによるオブジェクトアクセスの監査
  2. 02所有権とテナント範囲の確認
  3. 03オブジェクトを更新・削除するエンドポイントの調査

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/utkusen/sast-skills --skill sast-idor

インストール後、Claude Code で/sast-idorを入力して読み込まれたか確認します。

詳細

IDOR(安全でない直接オブジェクト参照)の検出

コードベース内の IDOR 脆弱性を見つけるため、対象を絞ったセキュリティ評価を行います。このスキルでは、サブエージェントを使って、偵察(候補となるエンドポイントの発見)、バッチ検証(3 件ずつ並列で認可を確認)、統合(結果の集約)の 3 段階で進めます。

前提条件:sast/architecture.md が存在している必要があります。存在しない場合は、先に分析スキルを実行してください。


IDOR とは

IDOR は、アプリケーションがユーザー提供の識別子(ID、スラッグ、ファイル名など)を使ってオブジェクトに直接アクセスし、リクエストしたユーザーにその特定のオブジェクトへのアクセス権があるか確認しない場合に発生します。アプリケーションはユーザーを認証していても、要求されたリソースの所有権や権限を確認できていません。

基本的なパターン:認証済みのユーザー A が、リクエスト内の識別子を変更してユーザー B のリソースにアクセスしたり、変更したりできる。

IDOR に該当するケース
  • /api/orders/1001 を /api/orders/1002 に変更し、別のユーザーの注文を閲覧する
  • 自分が所有していないドキュメントを削除するために、DELETE /api/documents/555 を送信する
  • リクエスト本文の {"account_id": 789} を変更して、他人のアカウントから送金する
  • ファイルのダウンロードパラメーター ?file_id=42 を変更して、他のユーザーの非公開ファイルにアクセスする
  • PUT /api/users/other-user-id を使って、他のユーザーのプロフィールを更新する
IDOR に該当しないケース

以下は IDOR として報告しないでください。

  • 認証の欠如:エンドポイントにログインがまったく不要な場合 → これは別の分類である「未認証アクセス」です
  • 機能レベルのアクセス制御の不備:一般ユーザーが /admin/dashboard にアクセスできる場合 → これは垂直方向の権限昇格であり、IDOR ではありません
  • 公開リソース:投稿が意図的に公開されている /api/posts/123 にアクセスすることは IDOR ではありません
  • オブジェクト以外のフィールドに対するパラメーター改ざん:リクエスト内の role=admin や price=0 を変更する場合 → これは一括割り当てまたはビジネスロジックの問題であり、IDOR ではありません
  • ID フィールドを介した SQL インジェクション:?id=1 OR 1=1 → これは SQLi であり、IDOR ではありません
IDOR を防ぐ認可パターン

以下のパターンが見られる場合、そのエンドポイントに脆弱性はない可能性が高いです。

1. 現在のユーザーでスコープされたクエリ(最も一般的な修正方法)

# The query itself ensures only the user's own records are returned
Order.objects.filter(id=order_id, user=request.user)       # Django
current_user.orders.find(params[:id])                       # Rails
Order.findOne({ _id: orderId, userId: req.user.id })        # Mongoose
SELECT * FROM orders WHERE id = ? AND user_id = ?           # Raw SQL

2. 取得後の明示的な所有権チェック

order = Order.find(order_id)
if order.user_id != current_user.id:
    raise Forbidden

3. ポリシー/権限/認可ミドルウェア

authorize('view', order)                    # Laravel Policy
can?(:read, @order)                         # CanCanCan (Rails)
@PreAuthorize("@auth.ownsOrder(#orderId)")  # Spring Security

4. テナント/組織によるスコープ設定

# Multi-tenant apps that scope all queries to the tenant
tenant = get_current_tenant(request)
Order.objects.filter(id=order_id, tenant=tenant)

脆弱な例と安全な例

Python — Django
# VULNERABLE: fetches any order by ID, no ownership check
def get_order(request, order_id):
    order = Order.objects.get(id=order_id)
    return JsonResponse(model_to_dict(order))

# SECURE: query scoped to requesting user
def get_order(request, order_id):
    order = get_object_or_404(Order, id=order_id, user=request.user)
    return JsonResponse(model_to_dict(order))
Python — Flask / SQLAlchemy
# VULNERABLE
@app.route('/api/documents/<int:doc_id>')
@login_required
def get_document(doc_id):
    doc = Document.query.get_or_404(doc_id)
    return jsonify(doc.serialize())

# SECURE
@app.route('/api/documents/<int:doc_id>')
@login_required
def get_document(doc_id):
    doc = Document.query.filter_by(id=doc_id, owner_id=current_user.id).first_or_404()
    return jsonify(doc.serialize())
Node.js — Express / Mongoose
// VULNERABLE
router.get('/api/orders/:id', auth, async (req, res) => {
  const order = await Order.findById(req.params.id);
  res.json(order);
});

// SECURE
router.get('/api/orders/:id', auth, async (req, res) => {
  const order = await Order.findOne({ _id: req.params.id, userId: req.user.id });
  if (!order) return res.status(404).json({ error: 'Not found' });
  res.json(order);
});
Node.js — Express / Prisma
// VULNERABLE
router.get('/api/invoices/:id', auth, async (req, res) => {
  const invoice = await prisma.invoice.findUnique({ where: { id: req.params.id } });
  res.json(invoice);
});

// SECURE
router.get('/api/invoices/:id', auth, async (req, res) => {
  const invoice = await prisma.invoice.findFirst({
    where: { id: req.params.id, userId: req.user.id }
  });
  if (!invoice) return res.status(404).json({ error: 'Not found' });
  res.json(invoice);
});
Ruby on Rails
# VULNERABLE
def show
  @order = Order.find(params[:id])
end

# SECURE
def show
  @order = current_user.orders.find(params[:id])
end
Java — Spring Boot
// VULNERABLE
@GetMapping("/api/accounts/{id}")
public Account getAccount(@PathVariable Long id) {
    return accountRepo.findById(id).orElseThrow();
}

// SECURE
@GetMapping("/api/accounts/{id}")
public Account getAccount(@PathVariable Long id, Authentication auth) {
    Account acct = accountRepo.findById(id).orElseThrow();
    if (!acct.getOwnerId().equals(auth.getName()))
        throw new AccessDeniedException("Forbidden");
    return acct;
}
Go
// VULNERABLE
func GetOrder(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    order, _ := db.GetOrder(id)
    json.NewEncoder(w).Encode(order)
}

// SECURE
func GetOrder(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    userID := r.Context().Value("userID").(string)
    order, _ := db.GetOrderByUser(id, userID)
    json.NewEncoder(w).Encode(order)
}
PHP — Laravel
// VULNERABLE
public function show($id) {
    return Invoice::findOrFail($id);
}

// SECURE (scoped query)
public function show($id) {
    return auth()->user()->invoices()->findOrFail($id);
}

// SECURE (policy)
public function show($id) {
    $invoice = Invoice::findOrFail($id);
    $this->authorize('view', $invoice);
    return $invoice;
}
C# — ASP.NET Core
// VULNERABLE
[HttpGet("api/profiles/{id}")]
public async Task<IActionResult> GetProfile(int id) {
    var profile = await _db.Profiles.FindAsync(id);
    return Ok(profile);
}

// SECURE
[HttpGet("api/profiles/{id}")]
public async Task<IActionResult> GetProfile(int id) {
    var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
    var profile = await _db.Profiles.FirstOrDefaultAsync(p => p.Id == id && p.UserId == userId);
    if (profile == null) return NotFound();
    return Ok(profile);
}

実行

このスキルは、サブエージェントを使って 3 段階で実行されます。コンテキストとして sast/architecture.md の内容をすべてのサブエージェントに渡してください。

フェーズ 1:調査 — 候補エンドポイントを見つける

以下の指示を与えてサブエージェントを起動します。

目標:ユーザーが指定した識別子を使って、特定のオブジェクトを取得、変更、または削除するすべてのエンドポイント、コントローラーアクション、ハンドラーを見つけます。結果を sast/idor-recon.md に書き込みます。

コンテキスト:プロジェクトのアーキテクチャ概要が提供されます。それを使って、技術スタック、フレームワーク、ルート定義、データアクセスパターンを把握してください。

検索対象:

  1. ID パラメーターを含むルート定義:

    • パスパラメーター::id、{id}、<int:id>、[id]
    • 検索パターン:パラメータープレースホルダーを含むルート/パス/エンドポイント定義
  2. ID 引数を受け取り、それを使ってオブジェクトを取得または変更するコントローラー/ハンドラーメソッド:

    • ORM 検索:find(id)、findById()、get(id=)、objects.get()、findOne()、findUnique()、findFirst()、query.get()、where(id:)
    • 生クエリ:SELECT ... WHERE id = ? など
    • ユーザー指定の ID を使った削除、更新操作も探してください
  3. 操作に使われるリクエスト本文またはクエリパラメーターの ID:

    • req.body.userId、req.query.id、request.data['account_id'] など
  4. ID 引数を受け取るGraphQL リゾルバーとミューテーション

  5. ユーザー指定のパスまたはファイル名によるファイル/リソースアクセス

対象外:

  • 設計上、認証不要で公開されているエンドポイント
  • ロールベースのチェックで保護された管理者専用エンドポイント(別の種類に該当します)
  • 使用される ID が、認証済みユーザー本人の ID のみであるエンドポイント(例:GET /api/me/profile)
  • 静的アセットの配信

出力形式 — sast/idor-recon.md に書き込みます:

# IDOR Recon: [Project Name]

## Summary
Found [N] candidate endpoints that use user-supplied identifiers to access objects.

## Candidates

### 1. [Descriptive name]
- **File**: `path/to/file.ext` (lines X-Y)
- **Endpoint**: `METHOD /path/:param`
- **Identifier source**: [path param / query param / body field]
- **Operation**: [read / update / delete]
- **Object accessed**: [model/table name]
- **Code snippet**:

[relevant code]


[Repeat for each candidate]
フェーズ 2:検証 — 認可を確認する(バッチ処理)

フェーズ 1 の完了後、sast/idor-recon.md を読み、候補を各最大 3 件のバッチに分割します。バッチごとにサブエージェントを 1 つ並列で起動します。各サブエージェントは割り当てられた候補のみを検証し、それぞれのバッチファイルに結果を書き込みます。

バッチ分割手順(サブエージェントではなく、あなたがオーケストレーターとして実行します):

  1. sast/idor-recon.md を読み、番号付き候補セクション(### 1.、### 2. など)の数を数えます。
  2. 最大 3 件ずつのバッチに分けます。例:候補が 8 件の場合、3 バッチ(1〜3、4〜6、7〜8)です。
  3. 各バッチについて、recon ファイルから該当する候補セクションの全文を抽出します。
  4. すべてのバッチサブエージェントを並列で起動し、それぞれに割り当てた候補のみを渡します。
  5. 各サブエージェントは sast/idor-batch-N.md(N は 1 から始まるバッチ番号)に書き込みます。
  6. sast/architecture.md からプロジェクトの主要な言語/フレームワークを特定し、「脆弱な例とセキュアな例」セクションから該当する例のみを選択します。たとえば、プロジェクトが Node.js/Express と Prisma を使っている場合、「Node.js — Express / Prisma」と「Node.js — Express / Mongoose」の例のみを含めます。選択した例は、以下の [TECH-STACK EXAMPLES] で示されている箇所に、各サブエージェントへの指示として含めます。

各バッチサブエージェントには、バッチ固有の値を置き換えて、以下の指示を与えます。

目的:以下の IDOR(安全でない直接オブジェクト参照)の候補を検証し、適切な認可チェックが存在するかどうかを判断します。目的は、IDOR 脆弱性を見つけることです。結果は sast/idor-batch-[N].md に記録してください。

割り当てられた候補(調査フェーズから):

[割り当てられた候補セクションの全文を、元の番号を保持したままここに貼り付けてください]

コンテキスト:プロジェクトのアーキテクチャ概要が提供されます。これを利用して、認証メカニズム、ミドルウェアスタック、ORM のパターンを理解してください。

IDOR リファレンス — 確認する内容:

IDOR は、認証済みユーザーがリクエスト内の識別子を変更することで、別のユーザーに属するリソースにアクセスしたり、変更したりできる場合に発生します。水平権限昇格(ユーザー間)に注目してください。

IDOR ではないもの — 以下を IDOR として報告しないでください:

  • 認証の欠如:エンドポイントがログインなしで利用できる → これは「未認証アクセス」であり、IDOR ではありません
  • 機能レベルのアクセス制御不備:一般ユーザーが /admin/dashboard にアクセスできる → これは垂直権限昇格であり、IDOR ではありません
  • 公開リソース:投稿が意図的に公開されている場合、/api/posts/123 へのアクセスは IDOR ではありません
  • オブジェクト以外のフィールドに対するパラメーター改ざん:role=admin や price=0 への変更 → これは一括割り当てまたはビジネスロジックの問題であり、IDOR ではありません
  • ID フィールドを介した SQL インジェクション:?id=1 OR 1=1 → これは SQLi であり、IDOR ではありません

IDOR を防ぐ認可パターン — 以下のパターンがあれば、そのエンドポイントは安全である可能性が高いです:

  1. 現在のユーザーにスコープされたクエリ:クエリが認証済みユーザーの ID でフィルターされている(例:WHERE id = ? AND user_id = ?、current_user.orders.find(id)、Order.findOne({ _id: id, userId: req.user.id })、Order.objects.filter(id=order_id, user=request.user))
  2. 取得後の明示的な所有者チェック:コードがオブジェクトを取得し、返す前に resource.user_id == current_user.id を比較している
  3. ポリシー/権限/認可ミドルウェア:authorize('view', order)、can?(:read, @order)、@PreAuthorize("@auth.ownsOrder(#orderId)") などのフレームワーク認可
  4. テナント/組織のスコープ設定:すべてのクエリが現在のテナント/組織にスコープされている

このプロジェクトの技術スタックにおける脆弱な例と安全な例:

[TECH-STACK EXAMPLES]

各候補エンドポイントについて確認する内容:

  1. データベースクエリは認証済みユーザーにスコープされていますか?
    • クエリに、現在のユーザーと一致する user_id/owner_id/tenant_id フィルターが含まれていますか?
    • 関連を通じてクエリされていますか(例:current_user.orders.find(id))?
  2. 取得後に明示的な所有者/権限チェックがありますか?
    • コードは resource.user_id == current_user.id(または同等の処理)を比較していますか?
    • ポリシー/権限/認可チェックがありますか?
  3. このルートに認可ミドルウェアが適用されていますか?
    • ハンドラーが実行される前に、オブジェクトの所有者を検証するミドルウェアがありますか?
    • ミドルウェアチェーンをたどってください。ミドルウェアの名前から所有者チェックが行われると決めつけないでください。
  4. 変更(更新/削除)の場合、同じチェックが行われていますか?
    • 読み取りエンドポイントは保護されていても、書き込みエンドポイントは保護されていないことがあります
  5. 確認すべきエッジケース:
    • 認証チェックは存在するものの、条件付きでしか実行されませんか(例:特定のコンテンツタイプではスキップされる)?
    • チェックは if/else の一方の分岐にはありますが、もう一方にはありませんか?
    • ID を別のフィールドで送信すると、チェックを回避できますか?
    • 一括/バッチエンドポイントでは、バッチ全体だけでなく各項目もチェックされていますか?

分類:

  • 脆弱:対象オブジェクトに対する認可チェックが見つからない。ユーザー A がユーザー B のリソースにアクセスできます。
  • 脆弱の可能性あり:認証チェックは存在しますが、不完全、回避可能、または条件付きであるように見えます。
  • 脆弱ではない:適切な認可チェックが実装されています。
  • 手動レビューが必要:確信を持って判断できません(例:複雑なミドルウェアチェーン、または追跡が難しいサービスレイヤーで認可が行われている)。

出力形式 — sast/idor-batch-[N].md に記録してください:

# IDOR バッチ [N] の結果

## 検出結果

### [脆弱] エンドポイント名
- **ファイル**:`path/to/file.ext`(行 X-Y)
- **エンドポイント**:`METHOD /path/:param`
- **問題**:[不足している内容を明確に説明]
- **影響**:[攻撃者が実行できること — 他のユーザーの X の読み取り、他のユーザーの Y の削除など]
- **証拠**:[ルートからデータベースクエリまでのコードパスを示し、不足しているチェックを強調]
- **修正方法**:[このエンドポイントに対する具体的な修正]
- **動的テスト**:

[稼働中のアプリケーションでこの検出結果を確認するための curl コマンド、または手順。 正確なエンドポイント、HTTP メソッド、ヘッダー、およびレスポンスで確認する内容を記載してください。 <USER_B_TOKEN> や <USER_A_RESOURCE_ID> のようなプレースホルダートークンを使用してください。]


### [脆弱の可能性あり] エンドポイント名
- **ファイル**:`path/to/file.ext`(行 X-Y)
- **エンドポイント**:`METHOD /path/:param`
- **問題**:[チェックが不完全な点]
- **懸念事項**:[依然として悪用される可能性がある理由]
- **証拠**:[不十分/部分的なチェックを含むコードパスを示す]
- **修正方法**:[具体的な修正]
- **動的テスト**:

[稼働中のアプリケーションでこの検出結果を確認するための curl コマンド、または手順。 正確なエンドポイント、HTTP メソッド、ヘッダー、およびレスポンスで確認する内容を記載してください。 <USER_B_TOKEN> や <USER_A_RESOURCE_ID> のようなプレースホルダートークンを使用してください。]


### [脆弱ではない] エンドポイント名
- **ファイル**:`path/to/file.ext`(行 X-Y)
- **エンドポイント**:`METHOD /path/:param`
- **保護**:[どのように保護されているか — スコープされたクエリ/所有者チェック/ポリシー]

### [手動レビューが必要] エンドポイント名
- **ファイル**:`path/to/file.ext`(行 X-Y)
- **エンドポイント**:`METHOD /path/:param`
- **不確実な点**:[自動分析でステータスを判断できなかった理由]
- **確認の提案**:[手動で確認すべき内容]
フェーズ 3: マージ — バッチ結果の統合

すべてのフェーズ 2 バッチサブエージェントが完了したら、すべての sast/idor-batch-*.md ファイルを読み込み、単一の sast/idor-results.md にマージします。これはあなた(オーケストレーター)が直接行います — サブエージェントは不要です。

マージ手順:

  1. すべての sast/idor-batch-1.md、sast/idor-batch-2.md、... ファイルを読み込みます。
  2. 各バッチファイルからすべての検出事項を収集し、元の分類とすべての詳細フィールドを保持したまま、1 つのリストに結合します。
  3. エグゼクティブサマリーのために、すべてのバッチにわたる合計を集計します。
  4. 次の形式を使用して、マージされたレポートを sast/idor-results.md に書き込みます:
# IDOR Analysis Results: [Project Name]

## Executive Summary
- Candidates analyzed: [total across all batches]
- Vulnerable: [N]
- Likely Vulnerable: [N]
- Not Vulnerable: [N]
- Needs Manual Review: [N]

## Findings

[All findings from all batches, grouped by classification:
 VULNERABLE first, then LIKELY VULNERABLE, then NEEDS MANUAL REVIEW, then NOT VULNERABLE.
 Preserve every field from the batch results exactly as written.]
  1. sast/idor-results.md を書き込んだ後、すべての中間バッチファイル (sast/idor-batch-*.md) を削除します。

重要なリマインダー

  • sast/architecture.md を読み込み、その内容をコンテキストとしてすべてのサブエージェントに渡します。
  • フェーズ 2 はフェーズ 1 の完了後に実行する必要があります — これは偵察の出力に依存します。
  • フェーズ 3 はすべてのフェーズ 2 バッチの完了後に実行する必要があります — これはすべてのバッチ出力に依存します。
  • バッチサイズはサブエージェントあたり 3 件の候補です。候補が合計 1〜3 件の場合は、単一のサブエージェントを使用します。10 件の場合は、4 つのサブエージェント (3+3+3+1) を使用します。
  • すべてのバッチサブエージェントを並列で起動します — 順次実行しないでください。
  • 各バッチサブエージェントは、偵察ファイル全体ではなく、割り当てられた候補のテキストのみを受け取ります。これにより、各サブエージェントのコンテキストを小さく、焦点を絞った状態に保ちます。
  • 水平方向の権限昇格 (ユーザーからユーザーへ) に焦点を当てます。垂直方向の昇格 (ユーザーから管理者へ) は別のスキルです。
  • 判断に迷った場合は、「脆弱ではない」ではなく「手動レビューが必要」に分類します。セキュリティ評価では、偽陽性よりも偽陰性の方が有害です。
  • コードパス全体を追跡します: ルート → ミドルウェア → コントローラー → サービス → データアクセス。認可はどの層でも行われる可能性があります。
  • フレームワークの規約に注意を払います。Rails では、current_user.orders.find(id) は安全です。Express では、auth ミドルウェアがあるだけでは所有権がチェックされていることにはなりません。
  • 中間ファイルをクリーンアップします: 最終的な sast/idor-results.md を書き込んだ後、sast/idor-recon.md とすべての sast/idor-batch-*.md ファイルを削除します。

コメント