omamori 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/Gemfile +1 -1
  4. data/Gemfile.lock +34 -14
  5. data/README.md +202 -49
  6. data/README_ja.md +204 -46
  7. data/demo/ai_analysis_vulnerability.rb +3 -3
  8. data/demo/csrf_vulnerability.rb +1 -1
  9. data/demo/eval_vulnerability.rb +1 -1
  10. data/demo/idor_vulnerability.rb +2 -2
  11. data/demo/insecure_cookie_vulnerability.rb +1 -1
  12. data/demo/open_redirect_vulnerability.rb +1 -1
  13. data/demo/static_analysis_vulnerability.rb +1 -1
  14. data/demo/xss_vulnerability.rb +1 -1
  15. data/exe/omamori +2 -2
  16. data/lib/omamori/ai_analysis_engine/checklist_scanner.rb +143 -0
  17. data/lib/omamori/ai_analysis_engine/cli_clients/base.rb +43 -0
  18. data/lib/omamori/ai_analysis_engine/cli_clients/claude_code_client.rb +35 -0
  19. data/lib/omamori/ai_analysis_engine/cli_clients/codex_client.rb +47 -0
  20. data/lib/omamori/ai_analysis_engine/diff_splitter.rb +30 -22
  21. data/lib/omamori/ai_analysis_engine/fallback_client.rb +23 -0
  22. data/lib/omamori/ai_analysis_engine/judge_client.rb +45 -0
  23. data/lib/omamori/ai_analysis_engine/llm_client.rb +71 -0
  24. data/lib/omamori/ai_analysis_engine/prompt_manager.rb +96 -19
  25. data/lib/omamori/ai_analysis_engine/ruby_llm_context.rb +23 -0
  26. data/lib/omamori/ai_analysis_engine/sub_checks/audit_log_missing.yml +12 -0
  27. data/lib/omamori/ai_analysis_engine/sub_checks/csrf.yml +22 -0
  28. data/lib/omamori/ai_analysis_engine/sub_checks/insecure_deserialization_rce.yml +9 -0
  29. data/lib/omamori/ai_analysis_engine/sub_checks/insufficient_encryption.yml +19 -0
  30. data/lib/omamori/ai_analysis_engine/sub_checks/mass_assignment.yml +19 -0
  31. data/lib/omamori/ai_analysis_engine/sub_checks/open_redirect.yml +17 -0
  32. data/lib/omamori/ai_analysis_engine/sub_checks/sql_injection.yml +20 -0
  33. data/lib/omamori/ai_analysis_engine/sub_checks/xss.yml +37 -0
  34. data/lib/omamori/ai_settings.rb +273 -0
  35. data/lib/omamori/config.rb +89 -69
  36. data/lib/omamori/core_runner.rb +485 -286
  37. data/lib/omamori/doctor.rb +95 -0
  38. data/lib/omamori/parallel.rb +24 -0
  39. data/lib/omamori/report_generator/console_formatter.rb +48 -42
  40. data/lib/omamori/report_generator/html_formatter.rb +6 -6
  41. data/lib/omamori/report_generator/json_formatter.rb +1 -1
  42. data/lib/omamori/setup_files.rb +260 -0
  43. data/lib/omamori/static_analysers/brakeman_runner.rb +7 -7
  44. data/lib/omamori/static_analysers/bundler_audit_runner.rb +8 -6
  45. data/lib/omamori/version.rb +2 -2
  46. data/lib/omamori.rb +11 -11
  47. metadata +61 -14
  48. data/lib/omamori/ai_analysis_engine/gemini_client.rb +0 -71
data/README_ja.md CHANGED
@@ -8,78 +8,180 @@ Omamoriは、Rubyコードおよび依存関係に潜む潜在的なセキュリ
8
8
  人間がチェックした上で万が一に発生した脆弱性を、防いでくれる"かもしれません"。
9
9
 
10
10
  注意:
11
- AI解析は静的解析で診断できない脆弱性を発見することができますが、モデルの気分によって診断の精度にブレが生じやすいです。
12
- 複数回実行することで偽陰性を防ぐことができるかもしれません。
11
+ AI解析は静的解析で診断できない脆弱性を発見することができますが、LLMの診断結果は実行のたびにブレが生じやすいです。
13
12
 
14
13
  ## 特徴
15
14
 
16
- - ステージされた変更(`git diff --staged`)またはコードベース全体をスキャンしてセキュリティリスクを検出
15
+ - ステージされた変更(`git diff --staged`)、ブランチの差分(`--base`)、コードベース全体、または指定されたファイルやディレクトリをスキャン
16
+ - 35種類の脆弱性を1項目ずつyes/noで判定する高速チェック(TypeSafe Jev、OpenAI Decisionsなど)。引っかかった項目だけ、LLMが詳しいレポートを書きます
17
+ - 判定役とレポート役を、それぞれ自由に選べます。レポート役には、RubyLLMが対応するAPI(Gemini、Claude、OpenAIなど)のほか、Claude Code(`claude -p`)やCodex(`codex exec`)のサブスクリプションも使えます
17
18
  - BrakemanやBundler-Auditなどの静的解析ツールと連携
18
- - Gemini APIを活用し、AIによる高度なコード脆弱性検出
19
19
  - 複数のレポート形式に対応(コンソール、HTML、JSON)
20
- - `.omamorirc`ファイルによる柔軟な設定が可能
20
+ - `omamori init` をフラグだけで実行でき、`omamori doctor` で設定を確かめられるので、AIエージェントにセットアップを任せられます
21
+ - GitHub Actions / GitLab CI の設定ファイルを生成
22
+ - 検出精度を測るための問題集(`evals/`)
23
+ - `.omamorirc`ファイルによる設定と、`.omamoriignore`ファイルによるスキャン対象からの除外
21
24
 
22
- ## インストール
25
+ ## 仕組み
23
26
 
24
- アプリケーションのGemfileに以下を追加します:
27
+ omamoriのAI解析は、2つの役割で動きます。
25
28
 
26
- ```ruby
27
- gem 'omamori'
28
- ```
29
+ - **判定役(classifier):** 35項目を1つずつyes/noで判定する、高速な判定モデルです。TypeSafe JevやOpenAI Decisionsなどがあります。使うかどうかは選べます。
30
+ - **レポート役(reporter):** 脆弱性のレポートを書くLLMです。判定役を使わない場合は、レポート役が全項目を一度に判定します。
29
31
 
30
- その後、以下を実行します:
32
+ 判定役を使うと、次の流れになります。
31
33
 
32
- ```bash
33
- bundle install
34
- ```
34
+ 1. 差分をファイルごとに分け、各ファイルについて全チェック項目(既定は35項目)を1リクエストでまとめて判定役に問い合わせます。ファイル同士は並列に処理します。
35
+ 2. 判定役が返す「脆弱性がある確率」が閾値未満の項目は、その場で通過とします。
36
+ 3. 閾値以上の項目だけ、レポート役がその脆弱性の詳細な検出手順に沿ってレポートを書きます。
35
37
 
36
- または、個別にインストールする場合は:
38
+ 判定役への質問には、レポート役に渡しているのと同じ脆弱性の定義と検出手順を含めています。XSS、SQLインジェクション、Mass Assignment、CSRF、オープンリダイレクト、監査ログの欠如、不十分な暗号化、安全でないデシリアライズは、「この変更に脆弱性があるか」という全体の問いに加えて、それ1つで脆弱と言える具体的な形(小項目。たとえばXSSなら「ユーザー由来のURLを、スキームを確認せずに `href` に入れていないか」)も1つずつ判定し、そのうち最も高い確率を使います。反応した小項目は、レポート役にも伝えます。
37
39
 
38
- ```bash
39
- gem install omamori
40
- ```
40
+ CSP、各種セキュリティヘッダー、本番の設定、2要素認証、パスワードの方針、監査ログなど「何かが無いこと」に関わる項目は、「この変更が既存の対策を弱めたり外したりしたか」を判定します。もともと設定が無いかどうかは、その設定に触れない変更のdiffからは判断できないためです。判定役がエラーを返したファイルは、全項目をレポート役に回します。レポート役がレポートを書けなかった項目も、判定役の検出結果として残します(判定を黙って飛ばしません)。
41
41
 
42
- ## 使い方
42
+ ## はじめかた
43
43
 
44
- ### 初期化
44
+ 1. Gemfileに追加してインストールします。
45
45
 
46
- 初期設定ファイル(`.omamorirc`)を生成するには、以下を実行します:
46
+ ```ruby
47
+ gem 'omamori', group: :development, require: false
48
+ ```
47
49
 
48
- ```bash
49
- omamori init
50
- ```
50
+ ```bash
51
+ bundle install
52
+ ```
53
+
54
+ 2. レポート役を決めて、設定ファイルを書き出します。プロバイダとモデルはフラグで指定します(対話式の質問はありません)。
55
+
56
+ ```bash
57
+ bundle exec omamori init --reporter claude_code # Claude Codeのサブスクリプション(claude -p)
58
+ bundle exec omamori init --reporter codex # Codexのサブスクリプション(codex exec)
59
+ bundle exec omamori init --reporter anthropic --reporter-model claude-sonnet-5-5 # RubyLLMが対応するAPI
60
+ ```
61
+
62
+ 判定役も使う場合は `--classifier typesafe` を、レポートを日本語にする場合は `--language ja` を、CIの設定も作る場合は `--ci github_actions`(または `gitlab_ci`)を足します。`.omamorirc` と `.omamoriignore` ができ、最後に次にやること(設定する環境変数など)が表示されます。既にある `.omamorirc` は、`--force` を付けない限り上書きしません。
63
+
64
+ 3. 表示された環境変数を設定し、つながるかを確かめます。
65
+
66
+ ```bash
67
+ bundle exec omamori doctor
68
+ ```
69
+
70
+ 判定役とレポート役に小さなリクエストを1回ずつ送り、足りないもの(APIキー、CLIのインストールやログイン)を直し方つきで表示します。すべて通れば終了コード0、どれかが失敗すれば1を返します。
51
71
 
52
- 生成された`.omamorirc`ファイルを編集して、Gemini APIキー、使用するモデル、実行するチェック項目などを設定します。
72
+ 4. スキャンします。
73
+
74
+ ```bash
75
+ bundle exec omamori scan --base main
76
+ ```
77
+
78
+ レポート役には既定値がありません。`.omamorirc` にも `--reporter` にも指定が無いと、`omamori scan` は `init` の使い方を表示して止まります。
79
+
80
+ ### APIキー
81
+
82
+ APIキーは環境変数で渡します。名前はRubyLLMの設定名を大文字にしたもの(`ANTHROPIC_API_KEY`、`OPENAI_API_KEY`、`GEMINI_API_KEY`、`OPENROUTER_API_KEY`、判定役のJevなら `TYPESAFE_API_KEY` など)で、`omamori init` が使うものを表示します。`claude_code` / `codex` は、インストール済みのCLIのログイン状態を使うので、APIキーは不要です。
83
+
84
+ コードの差分は、選んだ判定役とレポート役のプロバイダに送られます。社内の規程で外部に送れないコードには、Ollamaで動かすモデルなど、手元で動くものを選んでください。
85
+
86
+ ## AIエージェントにセットアップを任せる
87
+
88
+ Claude CodeやCodexなどのAIエージェントに「omamoriを入れて」と頼む場合、エージェントは次の手順で進められます。
89
+
90
+ 1. Gemfileに `gem 'omamori', group: :development, require: false` を足し、`bundle install` する
91
+ 2. レポート役を決める。`claude` コマンドがあれば `claude_code`、`codex` があれば `codex`。どちらも無ければ、使っているAPIのプロバイダとモデルを開発者に聞く
92
+ 3. 判定役(`--classifier typesafe` など)を使うかを開発者に聞く。使うと速く安くなり誤検知も減るが、そのプロバイダのAPIキーが要り、コードがそのプロバイダにも送られる
93
+ 4. `bundle exec omamori init --reporter ... [--reporter-model ...] [--classifier ...] [--language ja] [--ci github_actions]` を実行する
94
+ 5. 表示された環境変数を、開発者に設定してもらう。APIキーをチャットに貼ってもらったり、ファイルに書いたりしない
95
+ 6. `bundle exec omamori doctor` を、終了コードが0になるまで直しては実行する
96
+ 7. `bundle exec omamori scan --base main` を実行し、結果を開発者に伝える。CIの設定を作った場合は、登録するシークレットの名前(`init` が表示する)も伝える
97
+
98
+ ## 使い方
53
99
 
54
100
  ### スキャン
55
101
 
102
+ `.omamorirc` の設定を、その場だけフラグで変えることもできます(判定役とレポート役を選ぶ を参照)。
103
+
56
104
  ステージされた変更をスキャン(デフォルト):
57
105
 
58
106
  ```bash
59
107
  omamori scan
60
108
  ```
61
109
 
110
+ ブランチの差分をスキャン(`main...HEAD`。プルリクエストに含まれる変更。CI向け):
111
+
112
+ ```bash
113
+ omamori scan --base main
114
+ ```
115
+
62
116
  コードベース全体をスキャン:
63
117
 
64
118
  ```bash
65
119
  omamori scan --all
66
120
  ```
121
+ このモードでは`.omamoriignore`ファイルが有効になります。
67
122
 
68
- 出力形式を指定(コンソール、HTML、JSON):
123
+ 特定のファイルやディレクトリを指定してスキャン:
69
124
 
70
125
  ```bash
71
- bundle exec omamori scan --format html
72
- bundle exec omamori scan --all --format json
126
+ omamori scan app/controllers/users_controller.rb app/models/user.rb config/routes.rb lib/
73
127
  ```
128
+ このモードでも`.omamoriignore`ファイルが有効になります。
74
129
 
75
- ### AI解析のみ実施
130
+ 出力形式を指定(コンソール、HTML、JSON):
131
+
132
+ ```bash
133
+ omamori scan --format html
134
+ omamori scan --all --format json
135
+ ```
76
136
 
77
- 静的解析ツールを使わず、AI解析のみを実行するには、`--ai`オプションを使用します:
137
+ 静的解析ツールを使わず、AI解析のみを実行:
78
138
 
79
139
  ```bash
80
140
  omamori scan --ai
81
141
  ```
82
142
 
143
+ ### 判定役とレポート役を選ぶ
144
+
145
+ どちらの役も `provider` と `model` で指定します。コマンドラインでも、`.omamorirc`([設定](#設定))でも指定できます。
146
+
147
+ ```bash
148
+ omamori scan --base main --classifier typesafe --reporter gemini --reporter-model gemini-3.8-flash # Jevで判定、Geminiでレポート
149
+ omamori scan --base main --classifier typesafe --reporter claude_code # Jevで判定、claude -pでレポート
150
+ omamori scan --base main --classifier openai --reporter anthropic --reporter-model claude-sonnet-5-5
151
+ omamori scan --reporter codex # 判定役なし。codex execで全項目を判定
152
+ ```
153
+
154
+ **判定役は要るか?** 使わなくても構いません。レポート役を `claude -p`(sonnet)にして、Railsアプリの実際の脆弱性修正と、OSSのRailsアプリの普通のコミットで測ったところ、レポート役だけでも判定役ありと同じくらい脆弱性を拾いました。判定役を使うと、レポート役を呼ぶのが引っかかった項目だけになるので速く安くなり、危なそうに見えて安全な変更での誤検知も減りました。安いレポート役を単独で使った場合は測っていません。
155
+
156
+ **判定役(classifier)に指定できるprovider**
157
+
158
+ | provider | 判定モデル | APIキー |
159
+ | --- | --- | --- |
160
+ | `typesafe` | TypeSafe Jev(`model` 省略時は `jev-1.13.0`) | `TYPESAFE_API_KEY` |
161
+ | `openai` | OpenAI Decisions API(`model` 省略時は `gpt-6-luna`) | `OPENAI_API_KEY` |
162
+ | `ollama` | Ollamaで動かす判定モデル(Clefなど)。`model` と、接続先の `base_url`(または `OLLAMA_API_BASE`)が必須 | なし |
163
+ | `none` | 判定役を使わない(既定) | なし |
164
+
165
+ `base_url` を指定すると、SystemOneプロトコルに対応した他のサーバーにも向けられます。
166
+
167
+ **レポート役(reporter)に指定できるprovider**
168
+
169
+ | provider | 呼び出すもの |
170
+ | --- | --- |
171
+ | RubyLLMのプロバイダ(`gemini`、`anthropic`、`openai`、`openrouter`、`ollama` など) | [RubyLLM](https://rubyllm.com) 経由のAPI。`model` が必須 |
172
+ | `claude_code` | `claude -p`(サブスクリプション)。ツールをすべて無効にし、空の一時ディレクトリで実行 |
173
+ | `codex` | `codex exec`(サブスクリプション)。読み取り専用のサンドボックスで、空の一時ディレクトリで実行 |
174
+
175
+ `.omamorirc` では、レポート役の `provider` を省略することもでき、その場合はモデル名からプロバイダを決めます。RubyLLMのモデル一覧にない新しいモデルを使うときは、`provider` を指定してください。
176
+
177
+ レポート役は、内容によって判定を拒否することがあります(Geminiでは、セキュリティの分析を拒否することがあります)。拒否された項目は黙って通過させず、判定役の検出結果として残します。予備のレポート役(`reporter.fallback`)を設定すると、拒否やエラーのときだけ、そちらで聞き直します。
178
+
179
+ ```bash
180
+ omamori scan --base main --classifier typesafe --reporter-fallback claude_code --reporter-fallback-model sonnet
181
+ ```
182
+
183
+ diffには外部から来たコードが含まれるため、`claude_code` / `codex` はコマンドを実行できない状態で動かします。サブスクリプションのレート制限を考慮して、CLIを使うときのレポートの並列数は既定で3です(`reporter.concurrency` で変更できます)。
184
+
83
185
  ## 設定
84
186
 
85
187
  プロジェクトルートにある`.omamorirc`ファイルで、Omamoriの動作をカスタマイズできます。
@@ -90,19 +192,31 @@ omamori scan --ai
90
192
  # .omamorirc
91
193
  # omamori用設定ファイル
92
194
 
93
- # Gemini APIキー(AI解析に必須)
94
- # 環境変数GEMINI_API_KEYで設定することも可能
95
- api_key: YOUR_GEMINI_API_KEY # 実際のAPIキーに置き換えてください
96
-
97
- # 使用するGeminiモデル(任意、デフォルト: gemini-1.5-pro-latest)
98
- model: gemini-2.5-flash-preview-04-17
99
-
100
- # 有効化するセキュリティチェック(任意、デフォルトは全チェック)
195
+ # 判定役(任意)。省略するとレポート役が全項目を判定します。--classifier で上書きできます
196
+ # classifier:
197
+ # provider: typesafe # typesafe / openai / ollama
198
+ # model: jev-1.13.0 # 閾値を調整するなら、jev-latest ではなく版を固定する
199
+ # threshold: 0.5 # この確率以上の項目をレポート役に回す
200
+ # concurrency: 8 # 判定役に同時に投げるリクエスト数
201
+ # base_url: https://api.typesafe.ai # 任意。SystemOne形式の別のサーバーを使うとき
202
+ # api_key: ... # 任意。環境変数(TYPESAFE_API_KEY など)を推奨
203
+
204
+ # レポート役(必須。omamori init が書きます)。--reporter で上書きできます
205
+ reporter:
206
+ provider: anthropic # RubyLLMのプロバイダ、または claude_code / codex
207
+ model: claude-sonnet-5-5 # APIのプロバイダでは必須。CLIでは省略するとCLIの既定のモデル
208
+ # concurrency: 8 # レポートを並列で書く数(CLIの既定は3、APIの既定は8)
209
+ # command: /path/to/claude # 任意。claude_code / codex のCLIのパス
210
+ # api_key: ... # 任意。環境変数(ANTHROPIC_API_KEY など)を推奨
211
+ # fallback: # 任意。レポート役が拒否・失敗したときだけ聞く予備のレポート役
212
+ # provider: claude_code
213
+ # model: sonnet
214
+
215
+ # 実行するセキュリティチェック(任意。デフォルトは全35項目)
101
216
  # checks:
102
- # xss: true
103
- # csrf: true
104
- # idor: true
105
- # ... # 他のチェック項目も追加可能
217
+ # - xss
218
+ # - csrf
219
+ # - idor
106
220
 
107
221
  # カスタムプロンプトテンプレート(任意)
108
222
  # prompt_templates:
@@ -125,9 +239,9 @@ model: gemini-2.5-flash-preview-04-17
125
239
  # language: ja
126
240
  ```
127
241
 
128
- - `api_key`: Gemini APIへのアクセスキー。環境変数`GEMINI_API_KEY`でも設定可能。
129
- - `model`: AI解析に使用するGeminiモデル。デフォルトは`gemini-1.5-pro-latest`。
130
- - `checks`: 実行するセキュリティチェックの設定。特定のチェックを有効/無効にできます(例:`xss: true`, `csrf: false`)。
242
+ - `classifier`: 判定役。`provider`、`model`、`threshold`(デフォルト0.5)、`concurrency`(デフォルト8)、`base_url`、`api_key`。
243
+ - `reporter`: レポート役。`provider`、`model`、`concurrency`、`command`、`api_key`、`fallback`(予備のレポート役。`provider`、`model` など)。
244
+ - `checks`: 実行するセキュリティチェックのリスト。以前の項目名 `server_error_information_exposure` は `overly_detailed_errors` に統合されました。`dependency_trojan_package`(悪意のある依存パッケージ)は、コードからは正確に判断できないためAIの判定から外しました。既知の脆弱なバージョンはomamoriが一緒に動かすbundler-auditで、悪意のあるパッケージはOSV-ScannerやGitHubのDependency Reviewなど、悪意のあるパッケージのデータベースを持つツールで確かめてください。
131
245
  - `prompt_templates`: AI解析用のカスタムプロンプトテンプレートを設定。
132
246
  - `report`: レポート出力に関する設定。
133
247
  - `output_path`: HTMLおよびJSONレポートの出力先ディレクトリまたはプレフィックス。
@@ -135,6 +249,33 @@ model: gemini-2.5-flash-preview-04-17
135
249
  - `static_analysers`: 静的解析ツール(Brakeman、Bundler-Auditなど)の追加オプション設定。
136
250
  - `language`: AI解析結果の詳細説明文の言語設定。デフォルトは英語(`en`)。
137
251
 
252
+ 以前の書き方(`engine`、`llm`、`model`、`provider`、`api_key`、`decision`、`claude`、`codex`)と、以前のオプション(`--engine`、`--llm`)も、引き続き使えます。0.1系の設定ファイルで `api_key` だけを書いていた場合は、これまでどおりGemini(`gemini-3.8-flash`)を使います。
253
+
254
+ ## .omamoriignore ファイル
255
+
256
+ `.omamoriignore`ファイルを使用すると、特定のファイルやディレクトリをスキャン対象から除外できます。ただし、`--force-scan-ignored`オプションを指定した場合は、このファイルの設定は無視されます。これは`.gitignore`ファイルと似たような働きをしますが、以下の点に注意してください。
257
+
258
+ * **有効なモード:** `--all` モード、またはファイル/ディレクトリを指定してスキャンする場合にのみ有効です。
259
+ * **無効なモード:** `diff` モード(引数なしの `omamori scan`、または `--base`)では、`.omamoriignore` ファイルは無視されます。これは、`git diff` で取得された変更点のみを対象とするためです。
260
+ * **書式:** 1行に1つのパターンを記述します。`#` で始まる行はコメントとして扱われます。
261
+ * 末尾が `/` のパターンは、そのディレクトリ以下をすべて除外します(例: `vendor/bundle/`)。
262
+ * それ以外のパターンは、プロジェクトルートからの相対パスかファイル名に対するglobとして評価されます(例: `*.log`、`spec/fixtures/*.rb`)。
263
+
264
+ `omamori init` コマンドを実行すると、一般的なRailsプロジェクトで無視すべきファイルやディレクトリのパターンが記述された`.omamoriignore`ファイルが生成されます。必要に応じてこのファイルを編集してください。
265
+
266
+ ## CIで使う
267
+
268
+ `omamori init` に `--ci` を付けるか、あとから `.omamorirc` をもとに生成します:
269
+
270
+ ```bash
271
+ omamori ci-setup --ci github_actions # .github/workflows/omamori_scan.yml
272
+ omamori ci-setup --ci gitlab_ci # .gitlab-ci.yml
273
+ ```
274
+
275
+ 生成される設定は、プルリクエスト(マージリクエスト)では `--base` でブランチの差分を、それ以外では `--all` でコードベース全体をスキャンします。差分を取るため、リポジトリは履歴ごと取得します。`.omamorirc` の判定役とレポート役に合わせて、必要な環境変数を設定に書き、登録するシークレット(GitLabではCI/CD変数)の名前を表示します。
276
+
277
+ `claude_code` / `codex` は、そのマシンでのCLIのログインを使うものです。生成される設定は、CIのランナーにCLIのインストールやログインはしません。CIでは、スキャンのコマンドに `--reporter PROVIDER --reporter-model MODEL` を足してAPIのレポート役を使うか、ランナーの準備を自分で用意してください。
278
+
138
279
  ## デモファイル
139
280
 
140
281
  `demo`ディレクトリには、Omamoriの機能をデモンストレーションするための既知の脆弱性を含むサンプルファイルが置かれています。
@@ -153,18 +294,35 @@ model: gemini-2.5-flash-preview-04-17
153
294
  git add demo_
154
295
  ```
155
296
 
156
- 3. ステージしたデモファイルに対してOmamoriを実行します:
297
+ 3. ステージしたデモファイルに対してOmamoriを実行します(先に `omamori init --reporter ...` で設定しておきます):
157
298
 
158
299
  ```bash
159
300
  omamori scan
160
301
  ```
161
302
 
303
+ 判定役(Jev)で試す場合:
304
+
305
+ ```bash
306
+ omamori scan --classifier typesafe
307
+ ```
308
+
162
309
  または、ステージ後にすべてのファイルをスキャンする場合:
163
310
 
164
311
  ```bash
165
312
  omamori scan --all
166
313
  ```
167
314
 
315
+ ## 検出精度の測定
316
+
317
+ `evals/` には、正解のわかっているコード変更の問題集があります。35項目それぞれについて、脆弱な変更と「惜しいけど安全な」変更を2つずつ、さらに無害な変更を収録しています。XSS、SQLインジェクション、Mass Assignment、IDOR、CSRF、オープンリダイレクトには、見逃しやすい具体的な形のケースも追加しています。判定役やレポート役を変えたときに、見逃しと誤検知がどう変わるかを測れます。
318
+
319
+ ```bash
320
+ bundle exec ruby evals/run.rb --classifier typesafe --runs 3
321
+ bundle exec ruby evals/run.rb --classifier none --reporter claude_code --reporter-model sonnet
322
+ ```
323
+
324
+ 使い方は [evals/README.md](evals/README.md) を参照してください。
325
+
168
326
  ## 貢献について
169
327
 
170
328
  バグ報告やプルリクエストはGitHubで歓迎しています。このプロジェクトは、安全かつ協力的なコラボレーションの場を目指しています。
@@ -3,12 +3,12 @@
3
3
  require 'net/http'
4
4
  require 'uri'
5
5
 
6
- API_KEY = "HARDCODED_SECRET_API_KEY_12345" # Hardcoded sensitive information
6
+ API_KEY = 'HARDCODED_SECRET_API_KEY_12345' # Hardcoded sensitive information
7
7
 
8
8
  def fetch_data(user_input)
9
9
  # Insufficient input validation
10
10
  if user_input.length > 100
11
- puts "Input too long, truncating."
11
+ puts 'Input too long, truncating.'
12
12
  user_input = user_input[0..99]
13
13
  end
14
14
 
@@ -25,4 +25,4 @@ end
25
25
 
26
26
  # Example usage
27
27
  # fetch_data("some data")
28
- # fetch_data("a" * 200) # Long input
28
+ # fetch_data("a" * 200) # Long input
@@ -28,4 +28,4 @@ end
28
28
  # <input type="submit" value="Click me!">
29
29
  # </form>
30
30
  # または、JavaScriptを使って自動的にPOSTリクエストを送信させる。
31
- # ユーザーがログインした状態でこのページを閲覧すると、意図せずプロファイルが更新される可能性がある。
31
+ # ユーザーがログインした状態でこのページを閲覧すると、意図せずプロファイルが更新される可能性がある。
@@ -26,4 +26,4 @@ end
26
26
  # 2. ターミナルで `ruby demo/eval_vulnerability.rb` を実行
27
27
  # 3. ブラウザで `http://localhost:4567/calculate?expression=2%2B2` にアクセスすると "Result: 4" が表示される。
28
28
  # 4. 悪意のある入力を与える例: `http://localhost:4567/calculate?expression=system('ls%20-l')`
29
- # サーバー側で `ls -l` コマンドが実行されてしまう可能性がある。
29
+ # サーバー側で `ls -l` コマンドが実行されてしまう可能性がある。
@@ -16,7 +16,7 @@ get '/user_info/:user_id' do
16
16
  "User Info for ID #{user_id}: #{user_data}"
17
17
  else
18
18
  status 404
19
- "User not found."
19
+ 'User not found.'
20
20
  end
21
21
  end
22
22
 
@@ -36,4 +36,4 @@ end
36
36
  # 2. ターミナルで `ruby demo/idor_vulnerability.rb` を実行
37
37
  # 3. ブラウザで `http://localhost:4567/user_info/101` にアクセスすると Alice の情報が表示される。
38
38
  # 4. ログインしていない、または権限のないユーザーが `http://localhost:4567/user_info/102` にアクセスすると、
39
- # 本来アクセス権限がないはずの Bob の情報が表示されてしまう可能性がある。
39
+ # 本来アクセス権限がないはずの Bob の情報が表示されてしまう可能性がある。
@@ -22,4 +22,4 @@ end
22
22
  # 2. ターミナルで `ruby demo/insecure_cookie_vulnerability.rb` を実行
23
23
  # 3. ブラウザで `http://localhost:4567/login?user_id=test_user` にアクセス
24
24
  # 開発者ツールなどでクッキーを確認すると、session_id クッキーに HttpOnly, Secure, SameSite 属性が付与されていないことがわかる。
25
- # HTTPSでアクセスした場合でもSecure属性が付かない。
25
+ # HTTPSでアクセスした場合でもSecure属性が付かない。
@@ -19,4 +19,4 @@ end
19
19
  # 2. ターミナルで `ruby demo/open_redirect_vulnerability.rb` を実行
20
20
  # 3. ブラウザで `http://localhost:4567/redirect?url=https://malicious-site.example.com` にアクセス
21
21
  # 悪意のあるサイトにリダイレクトされてしまう。
22
- # フィッシング詐欺などに悪用される可能性がある。
22
+ # フィッシング詐欺などに悪用される可能性がある。
@@ -15,4 +15,4 @@ ensure
15
15
  end
16
16
 
17
17
  # Example usage (vulnerable)
18
- # find_user("' OR '1'='1")
18
+ # find_user("' OR '1'='1")
@@ -18,4 +18,4 @@ end
18
18
  # 1. このファイルを保存
19
19
  # 2. ターミナルで `ruby demo/xss_vulnerability.rb` を実行
20
20
  # 3. ブラウザで `http://localhost:4567/greet?name=<script>alert('XSS')</script>` にアクセス
21
- # アラートが表示されれば脆弱性がある
21
+ # アラートが表示されれば脆弱性がある
data/exe/omamori CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # frozen_string_literal: true
4
4
 
5
- require "omamori"
5
+ require 'omamori'
6
6
 
7
7
  # Main entry point for the omamori CLI
8
8
  module Omamori
@@ -13,4 +13,4 @@ module Omamori
13
13
  end
14
14
  end
15
15
 
16
- Omamori::CLI.start(ARGV)
16
+ Omamori::CLI.start(ARGV)
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../parallel'
4
+
5
+ module Omamori
6
+ module AIAnalysisEngine
7
+ # Runs each risk as an independent yes/no check with a fast decision model,
8
+ # then asks an LLM to write a report only for the checks that did not pass.
9
+ class ChecklistScanner
10
+ DEFAULT_THRESHOLD = 0.5
11
+ DEFAULT_CONCURRENCY = 8
12
+
13
+ Unit = Struct.new(:label, :content)
14
+
15
+ # Other risks get their own report, so each report sticks to its one risk to avoid duplicates
16
+ SINGLE_RISK_INSTRUCTION = "\nReport only the vulnerability type described above. " \
17
+ "If the code does not have it, return an empty list.\n"
18
+
19
+ def initialize(decision_client:, llm_client:, prompt_manager:, diff_splitter:, json_schema:, model:,
20
+ threshold: DEFAULT_THRESHOLD, concurrency: DEFAULT_CONCURRENCY, report_concurrency: concurrency,
21
+ sub_checks: true)
22
+ @decision_client = decision_client
23
+ @llm_client = llm_client
24
+ @prompt_manager = prompt_manager
25
+ @diff_splitter = diff_splitter
26
+ @json_schema = json_schema
27
+ @model = model
28
+ @threshold = threshold
29
+ @concurrency = concurrency
30
+ @report_concurrency = report_concurrency
31
+ @sub_checks = sub_checks
32
+ end
33
+
34
+ # units: [[label, content], ...] (a file path and its diff or source)
35
+ def scan(units, risks_to_check)
36
+ puts "Checking #{units.size} file(s) x #{risks_to_check.size} checks..."
37
+ classified = classify_units(units, risks_to_check)
38
+ total = classified.sum { |_chunk, probabilities| probabilities.size }
39
+
40
+ flagged = classified.flat_map do |chunk, probabilities, triggers|
41
+ probabilities.filter_map do |risk, probability|
42
+ [chunk, risk, probability, triggers[risk]] if flagged?(probability)
43
+ end
44
+ end
45
+ puts "#{flagged.size} check(s) flagged, #{total - flagged.size} passed."
46
+
47
+ risks = Parallel.map(flagged, @report_concurrency) do |chunk, risk, probability, risk_triggers|
48
+ report(chunk, risk, probability, risk_triggers)
49
+ end
50
+ { 'security_risks' => risks.flatten }
51
+ end
52
+
53
+ # Each risk is asked as an overall question plus its sub-checks; the risk's probability is the highest of them.
54
+ # Returns [[chunk, { risk => probability or nil }, { risk => [sub-check names at or above the threshold] },
55
+ # { question name => probability }], ...]
56
+ def classify_units(units, risks_to_check)
57
+ chunks = expand(units)
58
+ questions = risks_to_check.map { |risk| @prompt_manager.build_questions(risk, sub_checks: @sub_checks) }
59
+ .reduce({}, :merge)
60
+ parallel_map(chunks) do |chunk|
61
+ answers = classify(chunk, questions)
62
+ [chunk, aggregate(answers, risks_to_check), triggers(answers), answers]
63
+ end
64
+ end
65
+
66
+ # Asks the reporter to write a report for one flagged risk. Returns the reported risks (empty when the reporter
67
+ # finds nothing; a review note when the reporter could not answer).
68
+ def report(chunk, risk, probability, risk_triggers = [])
69
+ puts "Reporting #{risk} in #{chunk.label} (p=#{probability ? format('%.2f', probability) : 'n/a'})..."
70
+ prompt = @prompt_manager.build_prompt(chunk.content, [risk], @json_schema, file_path: chunk.label) +
71
+ SINGLE_RISK_INSTRUCTION + trigger_hint(risk_triggers)
72
+ result = @llm_client.analyze(prompt, @json_schema, model: @model)
73
+ result ? result.fetch('security_risks', []) : [unreported_flag(chunk, risk, probability)]
74
+ end
75
+
76
+ def flagged?(probability)
77
+ probability.nil? || probability >= @threshold
78
+ end
79
+
80
+ private
81
+
82
+ def expand(units)
83
+ units.flat_map do |label, content|
84
+ parts = @diff_splitter.split(content)
85
+ parts.each_with_index.map do |part, index|
86
+ Unit.new(parts.size > 1 ? "#{label} (chunk #{index + 1}/#{parts.size})" : label, part)
87
+ end
88
+ end
89
+ end
90
+
91
+ def classify(chunk, questions)
92
+ @decision_client.classify(chunk.content, questions)
93
+ rescue StandardError => e
94
+ # If the classifier fails, every check is escalated so nothing is silently skipped
95
+ puts "Decision API error for #{chunk.label}: #{e.message}"
96
+ questions.keys.to_h { |risk| [risk, nil] }
97
+ end
98
+
99
+ # nil (no answer) wins, so a risk with any unanswered question is escalated
100
+ def aggregate(answers, risks)
101
+ by_risk = answers.group_by { |name, _| PromptManager.risk_of(name) }
102
+ risks.to_h do |risk|
103
+ probabilities = by_risk.fetch(risk, []).map(&:last)
104
+ [risk, probabilities.empty? || probabilities.include?(nil) ? nil : probabilities.max]
105
+ end
106
+ end
107
+
108
+ def triggers(answers)
109
+ answers.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |(name, probability), result|
110
+ next unless name.to_s.include?(PromptManager::SUB_CHECK_SEPARATOR) && probability && probability >= @threshold
111
+
112
+ result[PromptManager.risk_of(name)] << name
113
+ end
114
+ end
115
+
116
+
117
+ # Tells the reporter which concrete patterns the classifier matched, so it checks those first
118
+ def trigger_hint(risk_triggers)
119
+ texts = Array(risk_triggers).filter_map { |name| @prompt_manager.sub_check_text(name) }
120
+ return '' if texts.empty?
121
+
122
+ "A classifier flagged these patterns in the code. Check each of them carefully:\n" +
123
+ texts.map { |text| "- #{text}\n" }.join
124
+ end
125
+
126
+ # Keeps the classifier's flag when the LLM could not write a report, so the finding is not lost
127
+ def unreported_flag(chunk, risk, probability)
128
+ {
129
+ 'type' => risk.to_s,
130
+ 'location' => chunk.label,
131
+ 'details' => "Flagged by the decision model (p=#{probability ? format('%.2f', probability) : 'n/a'}), " \
132
+ 'but the LLM could not write a report. Review this change manually.',
133
+ 'severity' => 'Info',
134
+ 'unreported' => true
135
+ }
136
+ end
137
+
138
+ def parallel_map(items, &)
139
+ Parallel.map(items, @concurrency, &)
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'tmpdir'
6
+
7
+ module Omamori
8
+ module AIAnalysisEngine
9
+ module CLIClients
10
+ # Runs an agent CLI as a plain LLM so it can use the user's subscription.
11
+ # The prompt contains untrusted code, so every CLI runs in an empty directory with tools disabled
12
+ # or a read-only sandbox.
13
+ class Base
14
+ def initialize(command: nil)
15
+ @command = command || default_command
16
+ end
17
+
18
+ # Same interface as LLMClient#analyze. Returns the parsed JSON or nil.
19
+ def analyze(prompt, json_schema, model: nil)
20
+ Dir.mktmpdir('omamori-llm') do |dir|
21
+ output, error, status = Open3.capture3(*build_command(dir, json_schema, model), stdin_data: prompt, chdir: dir)
22
+ unless status.success?
23
+ puts "Warning: #{@command} exited with #{status.exitstatus}: #{error.strip[0, 500]}"
24
+ return nil
25
+ end
26
+
27
+ result = parse(output, dir)
28
+ return result if result.is_a?(Hash) && result.key?('security_risks')
29
+
30
+ puts "Warning: Unexpected response from #{@command}."
31
+ nil
32
+ end
33
+ rescue Errno::ENOENT
34
+ puts "Warning: '#{@command}' command not found. Install it or choose another --llm."
35
+ nil
36
+ rescue JSON::ParserError => e
37
+ puts "Warning: Failed to parse the response from #{@command}: #{e.message}"
38
+ nil
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end