Skip to main content

型レベルセキュリティ:安全なAIコード生成の未来とは?

blog feature snyk code green

2026年6月4日

0 分で読めます

はじめに

コードの記述(および生成)がかつてない速さで進む一方で、セキュリティ脆弱性もこれまで以上の速さで生まれるという残念な副作用があります。LLMにコードへセキュリティ脆弱性を含めないよう指示しても、必ずうまくいくとは限りません。手作業であれ支援ツールを使う場合であれ、現在のソフトウェア開発の方法では、安全なコードを確実かつ一貫して、そして証明可能な形で書くには不十分であることが明らかになりつつあります。

Rustの人気の高まりは、妥当な労力と使いやすさを保ちながら、脆弱性のクラス全体を完全に排除できることを示しています。Rustは実質的に、メモリ破損に関する脆弱性のすべて(ほとんど)をコンパイル時に排除しました。では、なぜこの脆弱性クラスだけに限定する必要があるのでしょうか?

この記事では、Webアプリケーションのセキュリティ脆弱性がコンパイルできない(または型チェックを通らない)ようにコードを書く方法や、セキュア・バイ・デザインのライブラリ、あるいは適切に配置したライブラリラッパーによって、手作業でもLLMを使っても、脆弱性のクラス全体がコードに書かれるのを防ぐ方法について解説します。脆弱性のクラスを排除するために使える、PythonとRustのコードパターンも紹介します。

型を使う理由

型システムを適切に使えば、非常に強力なツールになります。型システムを使うと、システムの不変条件、つまりプログラム内のあらゆるデータに関するルール、特性、関係性をコードとして明示できます。役立つコメントを残したり、実行時アサーションを行ったりする(最初の顧客が重要な操作をするまで実行されない可能性もあります)代わりに、コードベース全体を通じて、add関数を呼び出すすべての箇所で、常に2つのint型だけが渡されることを保証できます。

私の経験上、極めて厳密な型を使って書いたコードは、そうでないコードよりも実行時のバグがかなり少なくなります。すべての引数、データ、入力について考える必要があるからです。適切に型で明示することでミスが大幅に減り、その結果、コードに残るのはビジネスロジックのバグだけになります。「整数の加算関数に文字列を渡してしまった」といったミスは起きません。

セキュリティ脆弱性の多くは、特定の種類のバグにすぎません。先ほどの主張が正しいとすれば、柔軟性の高い型システムを使って解決できるバグも数多くあるはずです。

Trusted Types

この記事は、一部Trusted Types Web APIに着想を得ています。このAPIは、XSSインジェクションのシンクが、適切なXSSサニタイザーでサニタイズされた値など、安全性が確認された値のみを受け入れるようにすることで、DOM XSSのほとんどの形態を効果的に緩和します。さらにCSPを使ってこのAPIを厳格化し、Trusted Types APIの使用を必須にできます。使用されていない場合はTypeErrorがスローされます。

Linuxカーネルでも、__userマクロを使った同様の仕組みが採用されており、ユーザーランドからのポインターが適切に扱われるようにしています。

以降では、この手法を一般化し、任意のセキュリティ脆弱性クラスに適用する方法を見ていきます。

IDORを解決する

Insecure Direct Object Reference(IDOR)は、API操作を行う際の認証や認可チェックの不足に起因する、厄介な脆弱性です。典型的な例として、連番のユーザーIDをパラメーターに取り、ユーザーデータを返すAPIエンドポイントがあります。ユーザーIDの値を変更すると、適切な認可チェックがないため、別のユーザーのデータが返されてしまいます。

統計的に、読者の皆さんはPythonを使っている可能性が高いでしょう。そこでまず、型ヒントだけを使ってPythonでこの脆弱性を解決する方法を見ていきます。

Pythonの場合

まず、脆弱な例から始めましょう。

import sqlite3

from fastapi import FastAPI, Depends

app = FastAPI()

DATABASE = "app.db"

def get_db():
    conn = sqlite3.connect(DATABASE)
    conn.row_factory = sqlite3.Row
    try:
        yield conn
    finally:
        conn.close()

@app.get("/balance/{user_id}")
def get_balance(user_id: int, db: sqlite3.Connection = Depends(get_db)):
    cursor = db.execute(f"SELECT balance FROM accounts WHERE user_id = {user_id}")
    row = cursor.fetchone()
    return {"uid": user_id, "balance": row["balance"]}

これはごく一般的なAPI呼び出しに見えます。整数のユーザーIDを渡し、データベースを検索して、結果をレスポンスとして返します。しかし、認証や認可のチェックを追加し忘れています。つまり、IDさえ分かれば誰でも他のユーザーの残高を取得できる、典型的なIDORです。脆弱性の報告を受け、報告者に報奨金を支払い、関数を次のように更新します。

@app.get("/balance/{user_id}")
def get_balance(user_id: int, db: sqlite3.Connection = Depends(get_db)):
    if not (check_authentication() and check_authorization()): return 403
    ...

これでユーザーデータは安全です。しかし、これは非常に簡単に起きるミスです。APIエンドポイント1つで認証チェックを1つ忘れただけでも、重大な影響につながる可能性があります。では、同じエンドポイントを型システムで保護し、この脆弱性が再発しないようにする方法を見てみましょう。

import sqlite3

from fastapi import FastAPI, Depends
from typing import Never, NewType, Final
from collections.abc import Generator
from contextlib import contextmanager

app = FastAPI()

DATABASE = "app.db"

def get_db():
...

UserID = NewType("UserID", int)

class AuthenticationProvider:
    def do_authn(self) -> "AuthenticationGuard":
        # do something to check the auth
        return AuthenticationGuard(guard=AuthenticationGuard._INSTANTIATION_TOKEN)  # pyright: ignore[reportPrivateUsage, reportArgumentType]

class AuthenticationGuard:
    _INSTANTIATION_TOKEN: Final = object()

    def __init__(self, *, guard: Never):
        # could also do some runtime checks to ensure caller
        assert guard is self._INSTANTIATION_TOKEN

class UncheckedUserID:
    __value: UserID

    def __init__(self, user_id: UserID):
        self.__value = user_id

    @contextmanager
    def ensure_authn(self, guard: AuthenticationGuard) -> Generator[UserID, None, None]:
        assert guard
        yield self.__value


@app.get("/balance/{user_id}")
def get_balance(unauth_user_id: UncheckedUserID = Depends(UncheckedUserID), db: sqlite3.Connection = Depends(get_db)):
    authguard = AuthenticationProvider().do_authn()
    with unauth_user_id.ensure_authn(authguard) as user_id:
        cursor = db.execute(f"SELECT balance FROM accounts WHERE user_id = {user_id}")
        row = cursor.fetchone()
        return {"uid": user_id, "balance": row["balance"]}

コードはかなり増えていますが、全体から見れば、認証・認可のコードに吸収される程度のものです。

新しいコードの主な変更点は、前の例のユーザーIDを表すintのような基本型を、そのまま受け渡ししないことです。入力データは不透明なクラスに抽象化され、認証と認可の手順をすでに完了したことを証明しなければアクセスできません。UncheckedUserIDクラスが(SQL式で使うなど)有用な処理を何もできないようにすることで、認証チェックを実施して「本物の」user_id値を取り出し忘れた場合、型チェッカーが確実にエラーを出します。

Pythonは動的な言語なので、有効なAuthenticationGuardを渡さずにチェックを迂回し、内部の値にアクセスすることも技術的には可能です。しかし、そのような望ましくないコードはPRレビューで検出されることを期待できます。一方、Rustなどの言語では、より強い保証が得られるため、たとえ不格好なコードを書いても、ラッパークラスの内部値に直接アクセスすることはできません。もちろんコード自体は実行されるため、インスタンスのメモリをダンプするなど極端な手段は取れます。しかし、認証の一貫性を確保したいだけなら、そんなことをする理由はありません。

Rustの場合

Rustの可視性指定子を使うと、ラップされたuser_id値にアクセスできるコードを厳密に制御できます。そのため、認証・認可チェックを確実に行う方法として、さらに信頼性が高まります。以下の例では、AxumのExtractorを使い、入力値が安全なラッパークラスに正しくデシリアライズされるようにしています。

use axum::{response::IntoResponse, routing::get, Json, Router};

mod unchecked_input {
    use axum::extract::{FromRequestParts, Path};
    #[derive(serde::Deserialize, serde::Serialize, Debug)]
    pub struct UserID(u32);

    #[derive(FromRequestParts)]
    pub struct UncheckedUserID {
        #[from_request(via(Path))]
        value: UserID,
    }

    impl UncheckedUserID {
        pub const fn ensure_authn(self, _: super::authentication::AuthenticationGuard) -> UserID {
            self.value
        }
    }
}

mod authentication {
    use std::marker::PhantomData;

    pub struct AuthenticationGuard {
        _internal: PhantomData<()>,
    }

    pub struct AuthenticationProvider {}
    impl AuthenticationProvider {
        pub const fn do_auth() -> Option<AuthenticationGuard> {
            // do something to check the auth
            Some(AuthenticationGuard {
                _internal: PhantomData,
            })
        }
    }
}

async fn get_balance(user_id: unchecked_input::UncheckedUserID) -> impl IntoResponse {
    match authentication::AuthenticationProvider::do_auth() {
        Some(guard) => {
            let user_id = user_id.ensure_authn(guard);
            // go and do some SQL
            Json(serde_json::json!({"uid": user_id, "balance": 0.0}))
        }
        None => Json(serde_json::json!({"error": "auth failure"})),
    }
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/balance/{user_id}", get(get_balance));

    let listener = tokio::net::TcpListener::bind("127.0.0.1:8000")
        .await
        .unwrap();
    axum::serve(listener, app).await.unwrap();
}

実用性

もちろん、この方法でセキュリティ脆弱性を緩和するには、特にすでに運用されているソフトウェアプロジェクトでは、組織全体の取り組みが必要です。こうしたパターンを一貫して適用するためのコーディング基盤も必要になります。主な方法は、ラッパーライブラリとカスタムリンタールールの2つです。

たとえばPythonの場合、組織としてFastAPIを軽量にラップしたMyOrgFastAPIライブラリの使用を必須にし、すべてのAPIエンドポイントがセキュリティ要件を満たすカスタム型を使うようにできます。エンドポイントでstr型の引数を受け取ることは禁止し、必ず何らかのUncheckedStringを使うようにします。

また、カスタムラッパーライブラリを保守したくない場合は、リンターのルールとして実装できます。開発者へのフィードバックはそこまで早くありませんが、基本型を禁止することで同じ安全策を実現できます。

どちらの方法を選んでも、あらゆるケースを適切に処理できるようになります。このコードパターンは、さまざまな脆弱性への対策にも自然に応用できます。

  • UncheckedStringからユーザーに返す生の文字列を取り出すには、適切なサニタイズが必要です。これによりXSSを緩和できます。

  • UncheckedStringをSQLクエリに連結することはできません。これにより、典型的なSQL脆弱性を緩和できます。

  • UncheckedStringをコマンド文字列に連結することはできません。これにより、コマンドインジェクションの脆弱性を緩和できます。

ほかにもさまざまな対策に応用できます。

こうしたパターンは、人間の開発者にもAIエージェントにも同じように適用できます。AIエージェントにあらゆる箇所で認証を徹底するよう指示しても、常に確実に実行されるとは限りません。しかし、コンパイル時やリンティング時に認証を強制すれば、非常に強力な対策になります。

まとめ

このように型レベルでセキュリティを実装することは、人間の開発者にもAIエージェントにも非常に強力な手段となります。特に既存のソフトウェアプロジェクトでは、導入にある程度のコストがかかるかもしれません。それでも、脆弱性のクラス全体を排除できるなど、大きなメリットが期待できます。

データの取り扱い方はプロジェクトによって大きく異なりますが、早い段階から検討しておけば、安全なコードの実現に大きく役立ちます。

ホワイトペーパー

Python環境に潜むAIセキュリティの危機

開発スピードが急上昇する中、AI環境が何にアクセスできるか、本当に把握できていますか?