Slide 1

Slide 1 text

AIエージェントと共創するセキュアAPI開発: シ フトレフトをどう組み込む? RiiiM | レバテックLAB登壇資料 Copyright © 3-shake, Inc. All Rights Reserved. 1

Slide 2

Slide 2 text

whoami 織茂駿斗 (おりも はやと👼) ID: @riiimparm 株式会社スリーシェイク Sreake事業部 セキュリティ周りの業務支援をしてます 最近はSRE業務にも手を出している AWS, Google Cloud, k8s | Python, Go 個人的によく使うAPI: 自分のドメインに立てているエンドポイント 2

Slide 3

Slide 3 text

Mission / Vision インフラをシンプルにして イノベーションが起こりやすい世界を作る 本来実現したかった『技術が生み出す価値の享受』『イノベーション』、 その前に立ちはだかる『複雑化・巨大化し続ける技術やプロセス』。 スリーシェイクはこれを社会の課題と捉え、私たちの持つ技術力によって 解決できるよう活動しています。 労苦(Toil)を無くすサービスを 適正な価格で提供し続ける 私たちが考える本当に必要なものは『適切なカルチャー』、 『適切なエンジニア供給と育成』、『適切なITソリューション』。 これらを、あらゆるレイヤーで誰もが恩恵を受けられる適正な価格で 提供し続け、私たちが社会の根幹を担っていくことを使命とします。 Copyright © 3-shake, Inc. All Rights Reserved. 3

Slide 4

Slide 4 text

Business 日本のSREをリードする あらゆるサービスを 連携するハブになる インフラ・アプリ・データ・セキュリティ・AI 全方位で顧客の内製化を推進する伴走支援 あらゆるSaaSをノーコードで連携する クラウド型ETL/データパイプラインSaaS 事業者が抱える セキュリティリスクをゼロに 「いいエンジニア」を あなたのチームに セキュリティ対策をワンストップで 実現する脆弱性診断SaaS ハイスキル人材の紹介とHR戦略支援の両輪で エンジニア組織の課題に併走 Copyright © 3-shake, Inc. All Rights Reserved. 4

Slide 5

Slide 5 text

No content

Slide 6

Slide 6 text

許可条件は、人が先に決めるもの。では効いているとどう言 う? 許可条件(誰が・何を・どの状態で・何を返すか)は人が先に決めるものです。では決めた後、それが実際に効いているとどうやって言う のか。今日はそこだけを、実測値とログで扱います。 前提 前提 今日はここだけ 許可条件 入力と権限の検証 対策の検証 主体・操作・対象・文脈と、業務の順序。この 内部 語彙をそのまま使います。 APIも生成AIも入力元。毎回検証する。 決めた許可条件が実際に効いているかを、ツー ル・テスト・ログで確かめます。 2

Slide 7

Slide 7 text

検証の設計:欠陥を仕込んで、ツールに解かせる 対象 仕込んだもの 検証用に構築した注文管理 認可の欠陥 件。露骨なもので API。FastAPI、23エンドポイン ト、マルチテナント。 4 はなく、レビューを通ってしま う形。 かけたもの 4 (Spectral/Semgrep/Schemathesis/ZAP) と、条件を分けた2種類のAI生成テス 汎用ツール 本 測ったもの 検出数、偽陽性・偽陰性の内 訳、生成されたテスト件数、実 行時間。 ト。 3 ① ② AIへ「セキュリティテストを書いて」と頼んだ場合,③許可条件表を 渡してAIへ「セキュリティテストを実行して」と頼んだ場合。この3つと比べないと、効いたのがAIなのか許可条件表なのか分かりませ 対照群を つ置きました。 汎用ツールだけ、 許可条件表を渡さずに ん。 AIを使ったか」ではなく「許可条件を文章にしたか」です。 比較の軸は「 3

Slide 8

Slide 8 text

検証対象システムの構成:テナント・利用者・サービス分離 (4人) Tenant A Tenant B ①ログイン user_a user_b ②JWT発行 JWTを発行するだけ role, tenant_id を含む manager_a manager_b ③Bearer JWT api-service 利用者 member manager member manager auth-service 参照のみ SQLite 共有 tenants users orders order_status_history 判定 +RW 認可判定はここに集約 : tenant_id / role 判定キー テナントA/Bそれぞれにmember/manager 1人ずつ、計4人。auth-serviceはtenants/usersを参照してJWTを発行するだけで、ordersには触れない。tenant_idとroleによる認可判定はapiservice側に集約している。 1 この分離が壊れていないかを、後半で つずつ確かめます。 4

Slide 9

Slide 9 text

23エンドポイントの内訳 API本体は4リソース23本。次のスライドで扱うのは、このうちD1〜D4に対応する4系統だけです。 注文管理 orders (8) internal/orders (4) tenants (5) users / health (6) 注文本体の 管理者専用の内部操作 テナント 組織 の管理 ユーザー管理・死活監視 GET /orders ← D2 一覧漏れ GET /internal/orders GET /tenants GET /users/me GET /orders/{id} ← D1 BOLA PATCH GET /tenants/{id} GET /users POST /orders /internal/orders/{id}/force- POST /tenants GET /users/{id} PATCH /orders/{id} ← D3 状態遷 complete ← D4 BFLA PATCH /tenants/{id} POST /users 移/BFLA POST GET /tenants/{id}/users PATCH /users/{id}/role DELETE /orders/{id} /internal/orders/{id}/reassign POST /orders/{id}/cancel GET /internal/orders/stats CRUD ( ) GET /health GET /orders/{id}/history GET /orders/{id}/items 4 (D1〜D4)は、orders に3件・internal/orders に1件。tenants/usersには無い。 認可の欠陥 件 5

Slide 10

Slide 10 text

4 仕込んだ欠陥 件と、その混入経路 ID エンドポイント 境界 D1 GET /orders/{id} 対象 欠陥 / BOLA 現実にこうなる理由 APIが存在確認だけで所有者を見ていない 詳細 最初に書いた取得処理のまま、認可 を足し忘れた / D2 GET /orders 対象 一覧 D3 PATCH /orders/{id} フロー D4 PATCH /internal/orders/{id}/force-complete 機能 / BFLA / BFLA APIに tenant_id のフィルタが無い 一覧 PATCHで pending から completed へ直接遷移 でき、かつ manager 限定の操作を member が 実行できる(ロールチェック無し) /internal/orders 側だけロール判定が丸ごと無い (認可処理そのものが欠落) 管理画面用に書いたクエリを一般 APIへ流用した 状態を文字列で受け取り、遷移表も ロールチェックも実装しなかった 内部用として後から生やした別ルー トに漏れた 4件とも仕様書どおりに動きます。OpenAPIにも適合します。「仕様に完全に適合したAPIでも、利用者Aに注文456を返せてしまう」という 状態を、実際に再現しています。 4 正常系テストは 件とも通ります。壊れているのは実装ではなく、判断です。 6

Slide 11

Slide 11 text

APIごとの想定:誰が・何をしたら・何が返るべきか 23エンドポイントのうち、今日の欠陥4件に対応する4系統の「あるべき挙動」です。この想定を、後のスライドで1つずつ壊しま 実装した す。 エンドポイント GET /orders/{id} GET /orders PATCH /orders/{id} /internal/orders/* 主体 → member・自テナントの注文 → member・他テナントの注文 → member → manager・approved→completed → member が実行 → pending→completed へ直接 → manager → member → 期待される応答 200 404 のはず ( 0 ) 自テナントの注文だけ 他テナント 件 200 403 のはず 409 のはず 200 403 のはず 4 この想定通りに動けば安全です。次のスライドで、この想定を か所だけ壊します。 7

Slide 12

Slide 12 text

0 出発点:認可を確かめるテストは 件でした 検証用リポジトリとして構築した時点の実測です。以下は実際にインストール・実行した結果で、加工はしていません。 項目 認可テスト追加 現在 備考 正常系実装は変更していません 前 エンドポイント数 23 23 テスト総数 7 33 うち拒否を確かめるテス 0 22 自動生成のみ Spectral検査あり カスタムルール 件、実行 — authz 0.5s / Spectral 0.8s / Schemathesis 12.8s GitHub Actions起動オーバーヘッドは含まない参考値 +26(正常系4+契約系3が土台。脆弱性実演4+拒否テスト 22は欠陥発見後に追加) 403/404を期待するもの。実行0.50秒 ト OpenAPI定義 ( ローカル実行時間 ジョ ) 4 0.8秒 ブ別実測 8

Slide 13

Slide 13 text

7 その 件の内訳 テスト 対象エンドポイント テストの目的 test_get_own_order_detail GET /orders/{id} 自分の注文が取得できる 正常系 test_list_own_orders GET /orders 自テナントの一覧が返る 正常系 test_manager_completes_approved_order PATCH /orders/{id} managerが承認済み注文を完了できる(正常系) test_manager_uses_internal_orders GET /internal/orders managerが内部一覧を取得できる(正常系) test_invalid_order_id_type_returns_422 PATCH /orders/{id} 型不正なリクエストが test_missing_body_field_returns_422 POST /orders 必須項目欠落が test_contract_layer_does_not_catch_d1 GET /orders/{id} 契約テストは認可欠陥を検出しないことの実演 ( ) ( ) 422になる(契約) 422になる(契約) 9

Slide 14

Slide 14 text

7 その 件、実際はどう書かれていたか 0 7 2 「拒否テスト 件」は数の問題ではありません。あった 件の中身を見ると、認可を確かめようとした形跡はあるのに、抜け方が 種類あり ました。 1 2 抜け方 :拒否のケースを一度も送っていない 抜け方 :脆弱な応答を「正しい」と書いてしまっている 対象: GET /orders/{id}(D1) 対象: GET /orders/{id}(D1) # test_happy_path.py # test_contract_openapi.py def test_get_own_order_detail(client, vuln_flags): def test_contract_layer_does_not_catch_d1(client, vuln_flags): vuln_flags(VULN_D1_BOLA_ORDER_DETAIL=False) vuln_flags(VULN_D1_BOLA_ORDER_DETAIL=True) r = client.get("/orders/o_7", r = client.get("/orders/456", headers=auth_headers("user_b")) assert r.status_code == 200 headers=auth_headers("user_a")) assert r.status_code == 200 assert r.json()["id"] == "o_7" user_bが自分の注文を取れることしか確認していない。他テナントのIDを 指定したら何が返るべきか、という行が1本も無い。 # ← user_aはtenant B # の注文456を取れてはいけない 404であるべきレスポ 契約テストとしては型どおりなので「合格」。だが本来 200と書いてしまい、欠陥を検知するどころか固定化していた。 ンスを 7 「テストがある」ことと「拒否条件を確かめている」ことは別。 件は前者だけを満たしていた。 10

Slide 15

Slide 15 text

「起きてほしくないこと」は、そのままではテストにならない 「十分に確かめられた?」という問いに答えるには、懸念を実行できる形へ落とす必要があります。ところが、ふだんの言い方には情報が 足りません。 4 レビューで出てくる言い方 足りない つ 「他人の注文が見えたらまずい」 誰が 何を どの状態で 何が返る 1 正しい懸念ですが、この文からはテストが 本も書けません。 → / / / 主体、対象、文脈、期待するレスポンス。許 4 可条件の 要素に、期待値を足したもので テストにできる形 → member の利用者Aが、テナントBの注文 IDを指定 → 404 ここまで書けて、はじめて自動化できます。 す。 AI 0 曖昧なまま渡すと、 は足りない部分を勝手に埋めます。埋めた結果は、ほぼ正常系のテストになります。後で出てくる「拒否テスト 件」 は、この埋められ方が原因でした。 自動化できないのはツールの問題ではなく、文が足りていないからです。 11

Slide 16

Slide 16 text

1 そこで、懸念とテストの間に ファイルだけ置く 目的はポリシー管理ではありません。拒否条件を一覧にして、数えられるようにするためだけのファイルです。 目的 1 目的 2 目的 3 数えられる AIが翻訳できる 拒否条件が何件あって、何件テスト済みかが分 同じ 行から、拒否テスト・ログの出力項目・レ コードを読まない人でも「この拒否は要る・要 かります。 「 件だった」と気づけたのは、この ビュー観点を展開できます。翻訳元が要りま らない」を判断できます。許可条件は業務側の 一覧を作ったからです。 す。 判断だからです。 0 人がレビューできる 1 OPA / Cedar / OpenFGA) 実行時に評価するもの( 今回置いたファイル リクエストごとに許可・拒否を判定し、本番の経路に入ります。今回は導入 実行時には読まれません。テストとレビューの入力にだけ使います。標準規 していません。 格ではなく、主体・操作・対象・文脈の 語をキーにしただけです。 4 12

Slide 17

Slide 17 text

Step 1:許可条件を、テストできる文章にする OpenAPIと認証コードからAIに生成させ、人が書くのは deny_when だ 主体・操作・対象・文脈を、そのままキーにしました。骨組みは けです。 # authz.yaml — 主体 × 操作 × 対象 × 文脈 - id: orders.read.own subject: { role: member, tenant: $self } action: read object: order context: order.tenant_id == subject.tenant_id deny_when: # ← 人が書くのはここだけ - "別テナントの注文IDを指定した" # 期待: 404 - "一覧に他テナントの注文が1件でも混ざる" # 期待: 0件 - id: orders.complete subject: { role: manager } action: transition object: order context: order.status == "approved" id / subject / action / object)はAIが生成 骨組み( # 順序が文脈 deny_when は人が書く。実装から読めないのはここだけだからです 13

Slide 18

Slide 18 text

3 テストはこの 段階で補強された AIに何をどう依頼したかで、拒否テストの数と質がどう変わったかを段階的に見ます。 Step 0:何も依頼していない Step 1:「このAPIのセキュリティテストを書いて」 7 出発点の 件 正常系・契約テスト・脆弱性実演のみ。拒否 0 ( → 56 表なし・自由裁量 8 ) テストは 件 スライド 参照 。 34件が拒否テスト。 deny_whenに無い実バグも1件発見。 件生成、うち Step 2:authz.yamlのdeny_whenを渡す → 51 表あり 45件が拒否テスト。 件生成、うち deny_whenを全パターンへ体系的に展開。 件数は右肩上がりだが、単純な「多いほど良い」ではない。次のスライドで中身を比較する。 14

Slide 19

Slide 19 text

対照群:表が無くても、コーディングエージェントは拒否テス トを書いた APIのセキュリティテストを書いて」 表なし「この authz.yaml の deny_when を全部テストに」 表あり「 # 生成56件のうち、authz.yamlに無い観点 # 生成51件のうち代表 def test_patch_order_cross_tenant @pytest.mark.parametrize("order_id", _status_change_should_be_denied(): # user_bがuser_aの注文をPATCHで # TENANT_B_ORDER_IDS) def test_denied_member_tenant_a_reads _tenant_b_order_detail(order_id): 承認できてしまわないか r = client.patch(f"/orders/{oid}", r = client.get(f"/orders/{order_id}", json={"status": "approved"}, headers=auth_headers("user_a")) headers=auth_b) assert r.status_code == 404 assert r.status_code == 404 22件、拒否系34件。主体4人(member/manager×テナントA/B)を横 断。deny_when に書いていない実バグ(PATCHのtenant漏れ)を発見。 正常系 45件、正常系6件。deny_when 5行を全パターン(ID×ロール×テ 拒否テスト ) ナント へ系統的に展開。 0 差は「書いたか 件か」ではなく、「書いてある項目を網羅したか」対「書いていない項目にも気づけたか」でした。 15

Slide 20

Slide 20 text

結果:汎用ツールの列は、ほぼ空欄になった(実測) Spectral Schemathesis Semgrep ZAP active scan D1 所有者を見ない詳細API — — △ — ● D2 一覧のテナント漏れ — — — — ● D3 状態遷移スキップ/ロール漏れ — — — — ● D4 別ルートのロール漏れ △ — — — ● 91 64 4 170 0 仕込んだ欠陥 GET /orders/{id} GET /orders PATCH /orders/{id} PATCH /internal/orders/{id}/force- 許可条件表 + AI生成 complete ( ) 検出以外に出た指摘 件数 △は「怪しいと気づけるが、欠陥とは言えない」ものです。SpectralはD4の内部ルート2本にdescription欠落を指摘しましたが、forcecomplete本体は素通りでした。Semgrepに自作ルールを書いても、D2は無関係なwhere句に紛れて偽陰性、D1は正しい実装への誤検知で した。汎用ツールが弱いのではなく、誰の注文かという業務の事実を、そもそも渡していないだけです。 4 渡していない情報は、検出されません。 本とも認可欠陥の検出実績はゼロでした。 16

Slide 21

Slide 21 text

許可条件 → 失敗 → 修正 → 成功 APIのセキュリティテストを書いて」で出てきた項目 × 許可条件表(deny_when) × 脅威モデリングの結果を掛け合わせて洗い出した 「起きてほしくないこと」なら、そこから先はテストの追加と実装の修正がセットでできます。D2で1本、その流れを追います。 「この 01 / 洗い出した項目 02 / 生成 03 / 実行 04 / 修正 表の該当行 拒否テスト 失敗ログ 実測 差分 実コード ( ) ( ) deny_when: r = get("/orders", FAILED test_d2_ - "一覧に他 h=auth_a) fixed_list_ テナントの ids = {o.id has_no_leak - 注文が混ざる" for o in r} assert not + def scope_orders 期待: 0件 leaked = ids & leaked TENANT_B_IDS leaked: 3 of 11 assert not leaked 1 1 - def scope_orders 05 / 再実行 ( ) 成功ログ 実測 PASSED _query(stmt, s): 2 passed return stmt in 0.48s _query(stmt, s): authz job(22件) + return stmt.where( 実行 0.5s tenant_b: o_7, + Order.tenant_id o_9, o_12 + == s.tenant_id) 1 洗い出せた項目 つが、失敗するテスト 本と修正 行に対応する。テストの追加も修正も、洗い出しさえ済めば機械的にできます。 17

Slide 22

Slide 22 text

AIエージェントに権限昇格を試させた記録(実ログ) TestClient経由でuser_a(memberロール、テナントA)の権限のまま2つのリクエストを送り、APIが出力した構造化ログをそのまま採 取しました。「LLMは入力元であって、権限の発行者ではない」ことの実測です。 実際に # user_a(member, tenant=1)のトークンでの実リクエスト > GET /orders/456 [api] (注文456はテナント2所属) 404 Not Found authz=deny rule=orders.read.own reason=tenant_mismatch subject=user_a role=member tenant=1 trace=f83a2896 object.tenant=2 > PATCH /orders/d3_deny_role {"status": "completed"} [api] (テナント1・承認済み) 403 Forbidden authz=deny rule=orders.complete reason=role_insufficient subject=user_a role=member tenant=1 trace=9802c848 order=d3_deny_role to_status=completed user_aの権限とリソースの関係は変わりません。ログに残しているのは rule 名と reason で、トークンとプロンプ 呼び出し方を変えても、 ト本文は残していません。 18

Slide 23

Slide 23 text

CIに載せる:どの段をどこで回すか(ローカル実測) PRごと / 0.8s PRごと / 12.8s PRごと / 0.5s 夜間 設計を静的に検査 契約と想定外入力 業務上の拒否 動的スキャン Spectral。security未定義の operationとルート命名のぶれを Schemathesis。型と境界は拾い ますが、認可は拾いません(実 測:2311ケース/64失敗、認可欠陥 0件)。 pytest。authz.yaml の deny_when から生成した拒否テスト22件。落 ZAP Automation Framework(active scan)。実測3 分7秒、PRに載せると遅すぎるの 落とします。 ちたらマージできません。 / 3m07s で夜間へ移しました。 14秒(GitHub Actions起動オーバーヘッドは含まない参考値)。うち拒否テストは0.5秒です。ここが「シフトレ ローカル実測で増えたのは約 フトをどう組み込むか」の答えで、重い動的スキャンを左に寄せるのではなく、軽い業務認可テストを左に置くのが現実的でした。 19

Slide 24

Slide 24 text

AI 軽いテストでシフトレフトを図る: の推論をどこに向けるか Spectral・Schemathesis・ZAPが重いのは、コードや通信を総当たりで調べているからです。AIエージェントには認可条件の翻訳だけを任 せれば、テストは軽いままシフトレフトできます(Unit/Integrationレベルでの話。今回の検証範囲もここまでです)。ただし条件がありま す。推論を向ける先が、汎用ツールの担当外のコンテキストであることです。 汎用ツールの担当 AIの推論に向けるべき領域 構文・型・スキーマ・既知パターン 認可・業務ロジック ルールで機械的に判定できる領域です。静的解析や契約テストで十分に拾え 「誰が」「どの状態で」「何をしてよいか」。ルール化できない業務の文脈こ ます 実測 そ、推論が要る領域です 実測 ( 0.8〜12.8秒)。 ( 0.5秒)。 AIに何でも検証させようとすると、汎用ツールと同じ土俵で戦うことになり重くなります。汎用ツールが構造的に届かない領域だけに推論 を絞るから、軽いまま左に置けます。 AIの使用量ではなく、AIに向けるコンテキストの選び方です。 シフトレフトを軽くする鍵は、 20

Slide 25

Slide 25 text

実測でわかった、うまくいかなかったこと Semgrepルールも一長一短 表に忠実なほど、表の外のバグを見逃す 自作 authz.yaml通りに45件の拒否テストを系統的に生成したエージェントは、表 に無かった実バグ(PATCHのtenant漏れ)を1件も見つけませんでした。同じ D2(GET /orders、一覧漏れ)狙いのルールは無関係なwhere句に紛れて素通 り(偽陰性)、D1(GET /orders/{id}、BOLA)狙いのルールは正しい実装を誤 検知(偽陽性)。パターンマッチだけでは業務ロジックの正しさを判定できま コードを見た「表なし」エージェントは指示なしにこれを発見しています。 せん。 2 実装そのものに粗が 件見つかった PATCH /orders/{id}のtenant一致チェック漏れと、Authorization欠落時に401 ではなく422を返す挙動。どちらもauthz.yamlには書いていなかった観点で ZAPは動的スキャンとして機能したが認可には無力 3分7秒かけて6種のアラートを検出しましたが、認可欠陥への言及は0件。 PRに載せず夜間実行にするのが現実的でした。 した。 AIが速くしたのは実装とテストを書く作業だけではなかったという点です。自由裁量のAIは、人が書いた表には無 実測してわかったのは、 かった拒否条件そのものにも気づけました。ただしその気づきは実行ごとに変わりうる、再現性の無いものです。だから「人が決めて終わ AIの気づきを人が脅威モデリングとして体系化し、表に書き戻す循環が必要でした。 り」ではなく、 AIの推論は、人間の脅威モデリングで補強して初めて再現性を持つ。表を書く→AIが探索する→気づきを表に戻す、の往復が要りました。 21

Slide 26

Slide 26 text

3 明日からやるなら、この つだけ 1フロー選ぶ 拒否条件を書き出す AIに拒否テストへ翻訳させる いちばん怖いエンドポイントを 本。全部を対象 「起きてほしくないこと」を主体つきの文で、 書いた分を に渡して生成し、落ちることを確 にすると始まりません。 思いつく数だけ。 認してから直します。 1 AI そしてそのテストに責任を持つ。テストを形骸化させない ツールを増やすのではなく、認可テストの仕組みを敷きます。 22

Slide 27

Slide 27 text

まとめ ( ) 汎用ツールは認可を見つけない 実測 Spectral/Semgrep/Schemathesis/ZAPの4件 中、確定検出は0件。業務の事実を渡していない からです。 差を作るのは網羅性と再現性 34件(自由裁量、実バグを1件発見)、表 ありは45件(deny_whenを全パターンへ体系的に 展開)。ただし表ありは表に無い項目を見逃しま 表なしは 軽いテストなら左に置ける シフトレフトは工程の前倒しではなく、判断の 隣に軽い検証を置くことでした。 した。 今日の主張 AI 「 エージェントなら拒否される経路をテストできる」は、条件つきで正しいと言えます。ツールアクセスのあるコーディングエージェントは、表が無くても自 AIはそれ 由裁量で相当数の拒否テストを書けました。しかし表に無い観点は、表に忠実なほど見逃されます。共創の分担は、人が拒否条件を決めて表に残し、 を網羅的なテストへ翻訳しつつ、表の外もカバーするという二段構えにあります。 AIに検証を任せる前に、拒否したい1行を書く。それでも表の外は、AI自身の裁量にも頼ることになる。 23

Slide 28

Slide 28 text

ありがとうございました! Copyright © 3-shake, Inc. All Rights Reserved. 1