omamori 0.1.1 → 0.1.4

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.
@@ -1,16 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'optparse'
4
+ require 'fileutils' # FileUtils を require する
5
+ require 'pathname' # Pathname を require する
4
6
  require_relative 'ai_analysis_engine/gemini_client'
5
- require_relative 'ai_analysis_engine/prompt_manager' # Require PromptManager
6
- require_relative 'ai_analysis_engine/diff_splitter' # Require DiffSplitter
7
- require_relative 'report_generator/console_formatter' # Require ConsoleFormatter
8
- require_relative 'report_generator/html_formatter' # Require HTMLFormatter
9
- require_relative 'report_generator/json_formatter' # Require JSONFormatter
10
- require_relative 'static_analysers/brakeman_runner' # Require BrakemanRunner
11
- require_relative 'static_analysers/bundler_audit_runner' # Require BundlerAuditRunner
12
- require 'json' # Required for JSON Schema
13
- require_relative 'config' # Require Config class
7
+ require_relative 'ai_analysis_engine/prompt_manager'
8
+ require_relative 'ai_analysis_engine/diff_splitter'
9
+ require_relative 'report_generator/console_formatter'
10
+ require_relative 'report_generator/html_formatter'
11
+ require_relative 'report_generator/json_formatter'
12
+ require_relative 'static_analysers/brakeman_runner'
13
+ require_relative 'static_analysers/bundler_audit_runner'
14
+ require 'json'
15
+ require_relative 'config'
14
16
 
15
17
  module Omamori
16
18
  class CoreRunner
@@ -53,41 +55,44 @@ module Omamori
53
55
  "required": ["security_risks"]
54
56
  }.freeze # Freeze the hash to make it immutable
55
57
 
56
- # TODO: Get risks to check from config file
57
- RISKS_TO_CHECK = [
58
+ # Default risks to check, can be overridden by config
59
+ DEFAULT_RISKS_TO_CHECK = [
58
60
  :xss, :csrf, :idor, :open_redirect, :ssrf, :session_fixation
59
- # TODO: Add other risks from requirements
61
+ # TODO: Add other risks from requirements based on PromptManager::RISK_PROMPTS.keys
60
62
  ].freeze
61
63
 
62
- # TODO: Determine threshold for splitting based on token limits
63
- SPLIT_THRESHOLD = 7000 # Characters as a proxy for tokens
64
+ # Threshold for splitting large content (characters as a proxy for tokens)
65
+ # Can be overridden by config
66
+ DEFAULT_SPLIT_THRESHOLD = 8000 # Characters
64
67
 
65
68
  def initialize(args)
66
69
  @args = args
67
- @options = { command: :scan, format: :console } # Default command is scan, default format is console
68
- @config = Omamori::Config.new # Initialize Config
70
+ @options = { command: :scan, format: :console } # Default command and format
71
+ @target_paths = []
72
+ @config = Omamori::Config.new
69
73
 
70
- # Initialize components with config
71
- api_key = @config.get("api_key", ENV["GEMINI_API_KEY"]) # Get API key from config or environment variable
72
- gemini_model = @config.get("model", "gemini-1.5-pro-latest") # Get Gemini model from config
74
+ # Initialize components with configuration
75
+ api_key = @config.get("api_key", ENV["GEMINI_API_KEY"])
76
+ gemini_model = @config.get("model", "gemini-2.5-flash-preview-04-17")
73
77
  @gemini_client = AIAnalysisEngine::GeminiClient.new(api_key)
74
- @prompt_manager = AIAnalysisEngine::PromptManager.new(@config) # Pass the entire config object
75
- # Get chunk size from config, default to 7000 characters if not specified
76
- chunk_size = @config.get("chunk_size", SPLIT_THRESHOLD)
77
- @diff_splitter = AIAnalysisEngine::DiffSplitter.new(chunk_size: chunk_size) # Pass chunk size to DiffSplitter
78
- # Get report output path and html template path from config
78
+ @prompt_manager = AIAnalysisEngine::PromptManager.new(@config)
79
+
80
+ chunk_size = @config.get("chunk_size", DEFAULT_SPLIT_THRESHOLD)
81
+ @diff_splitter = AIAnalysisEngine::DiffSplitter.new(chunk_size: chunk_size)
82
+
79
83
  report_config = @config.get("report", {})
80
84
  report_output_path = report_config.fetch("output_path", "./omamori_report")
81
- html_template_path = report_config.fetch("html_template", nil) # Default to nil, formatter will use default template
82
- @console_formatter = ReportGenerator::ConsoleFormatter.new # TODO: Pass config for colors/options
83
- @html_formatter = ReportGenerator::HTMLFormatter.new(report_output_path, html_template_path) # Pass output path and template path
84
- @json_formatter = ReportGenerator::JSONFormatter.new(report_output_path) # Pass output path
85
- # Get static analyser options from config
85
+ html_template_path = report_config.fetch("html_template", nil)
86
+
87
+ @console_formatter = ReportGenerator::ConsoleFormatter.new
88
+ @html_formatter = ReportGenerator::HTMLFormatter.new(report_output_path, html_template_path)
89
+ @json_formatter = ReportGenerator::JSONFormatter.new(report_output_path)
90
+
86
91
  static_analyser_config = @config.get("static_analysers", {})
87
- brakeman_options = static_analyser_config.fetch("brakeman", {}).fetch("options", {}) # Default to empty hash
88
- bundler_audit_options = static_analyser_config.fetch("bundler_audit", {}).fetch("options", {}) # Default to empty hash
89
- @brakeman_runner = StaticAnalysers::BrakemanRunner.new(brakeman_options) # Pass options
90
- @bundler_audit_runner = StaticAnalysers::BundlerAuditRunner.new(bundler_audit_options) # Pass options
92
+ brakeman_options = static_analyser_config.fetch("brakeman", {}).fetch("options", {})
93
+ bundler_audit_options = static_analyser_config.fetch("bundler_audit", {}).fetch("options", {})
94
+ @brakeman_runner = StaticAnalysers::BrakemanRunner.new(brakeman_options)
95
+ @bundler_audit_runner = StaticAnalysers::BundlerAuditRunner.new(bundler_audit_options)
91
96
  end
92
97
 
93
98
  def run
@@ -95,49 +100,99 @@ module Omamori
95
100
 
96
101
  case @options[:command]
97
102
  when :scan
98
- # Run static analysers first unless --ai option is specified
103
+ # Initialize results
104
+ ai_analysis_result = { "security_risks" => [] }
99
105
  brakeman_result = nil
100
106
  bundler_audit_result = nil
107
+
108
+ # Run static analysers first unless --ai option is specified
101
109
  unless @options[:only_ai]
110
+ puts "Running static analysers..."
102
111
  brakeman_result = @brakeman_runner.run
103
112
  bundler_audit_result = @bundler_audit_runner.run
104
113
  end
105
114
 
106
- # Perform AI analysis
107
- analysis_result = nil
115
+ # Perform AI analysis based on scan mode
108
116
  case @options[:scan_mode]
117
+ when :paths
118
+ # Scan specified files/directories
119
+ if @target_paths.empty?
120
+ puts "No paths specified for scan. Use --diff, --all, or provide paths."
121
+ else
122
+ puts "Scanning specified paths with AI..."
123
+ ignore_patterns = @config.ignore_patterns
124
+ force_scan_ignored = @options.fetch(:force_scan_ignored, false)
125
+ files_to_scan = collect_files_from_paths(@target_paths, ignore_patterns, force_scan_ignored)
126
+
127
+ if files_to_scan.empty?
128
+ puts "No Ruby files found in the specified paths."
129
+ else
130
+ files_to_scan.each do |file_path|
131
+ begin
132
+ file_content = File.read(file_path)
133
+ puts "Analyzing file: #{file_path}..." # スキャン中のファイルパスを表示
134
+ current_risks_to_check = get_risks_to_check
135
+ # @diff_splitterのインスタンス変数 @chunk_size を参照して比較
136
+ if file_content.length > @diff_splitter.instance_variable_get(:@chunk_size)
137
+ puts "File content exceeds threshold (#{@diff_splitter.instance_variable_get(:@chunk_size)} chars), splitting..."
138
+ file_ai_result = @diff_splitter.process_in_chunks(file_content, @gemini_client, JSON_SCHEMA, @prompt_manager, current_risks_to_check, file_path: file_path, model: @config.get("model", "gemini-2.5-flash-preview-04-17"))
139
+ else
140
+ prompt = @prompt_manager.build_prompt(file_content, current_risks_to_check, JSON_SCHEMA, file_path: file_path)
141
+ file_ai_result = @gemini_client.analyze(prompt, JSON_SCHEMA, model: @config.get("model", "gemini-2.5-flash-preview-04-17"))
142
+ end
143
+ # Merge results
144
+ if file_ai_result && file_ai_result["security_risks"]
145
+ ai_analysis_result["security_risks"].concat(file_ai_result["security_risks"])
146
+ end
147
+ rescue => e
148
+ puts "Error analyzing file #{file_path}: #{e.message}"
149
+ end
150
+ end
151
+ end
152
+ end
109
153
  when :diff
154
+ # Scan staged differences
110
155
  diff_content = get_staged_diff
111
156
  if diff_content.empty?
112
157
  puts "No staged changes to scan."
113
- return
114
- end
115
- puts "Scanning staged differences with AI..."
116
- if diff_content.length > SPLIT_THRESHOLD # TODO: Use token count
117
- puts "Diff content exceeds threshold, splitting..."
118
- analysis_result = @diff_splitter.process_in_chunks(diff_content, @gemini_client, JSON_SCHEMA, @prompt_manager, get_risks_to_check, model: @config.get("model", "gemini-1.5-pro-latest"))
119
158
  else
120
- prompt = @prompt_manager.build_prompt(diff_content, get_risks_to_check, JSON_SCHEMA)
121
- analysis_result = @gemini_client.analyze(prompt, JSON_SCHEMA, model: @config.get("model", "gemini-1.5-pro-latest"))
159
+ puts "Scanning staged differences with AI..."
160
+ current_risks_to_check = get_risks_to_check
161
+ # @diff_splitterのインスタンス変数 @chunk_size を参照して比較
162
+ if diff_content.length > @diff_splitter.instance_variable_get(:@chunk_size)
163
+ puts "Diff content exceeds threshold (#{@diff_splitter.instance_variable_get(:@chunk_size)} chars), splitting..."
164
+ ai_analysis_result = @diff_splitter.process_in_chunks(diff_content, @gemini_client, JSON_SCHEMA, @prompt_manager, current_risks_to_check, model: @config.get("model", "gemini-2.5-flash-preview-04-17"))
165
+ else
166
+ prompt = @prompt_manager.build_prompt(diff_content, current_risks_to_check, JSON_SCHEMA)
167
+ ai_analysis_result = @gemini_client.analyze(prompt, JSON_SCHEMA, model: @config.get("model", "gemini-2.5-flash-preview-04-17"))
168
+ end
122
169
  end
123
170
  when :all
171
+ # Scan entire codebase
124
172
  full_code_content = get_full_codebase
125
173
  if full_code_content.strip.empty?
126
174
  puts "No code found to scan."
127
- return
128
- end
129
- puts "Scanning entire codebase with AI..."
130
- if full_code_content.length > SPLIT_THRESHOLD # TODO: Use token count
131
- puts "Full code content exceeds threshold, splitting..."
132
- analysis_result = @diff_splitter.process_in_chunks(full_code_content, @gemini_client, JSON_SCHEMA, @prompt_manager, get_risks_to_check, model: @config.get("model", "gemini-1.5-pro-latest"))
133
175
  else
134
- prompt = @prompt_manager.build_prompt(full_code_content, get_risks_to_check, JSON_SCHEMA)
135
- analysis_result = @gemini_client.analyze(prompt, JSON_SCHEMA, model: @config.get("model", "gemini-1.5-pro-latest"))
176
+ puts "Scanning entire codebase with AI..."
177
+ current_risks_to_check = get_risks_to_check
178
+ # @diff_splitterのインスタンス変数 @chunk_size を参照して比較
179
+ if full_code_content.length > @diff_splitter.instance_variable_get(:@chunk_size)
180
+ puts "Full code content exceeds threshold (#{@diff_splitter.instance_variable_get(:@chunk_size)} chars), splitting..."
181
+ ai_analysis_result = @diff_splitter.process_in_chunks(full_code_content, @gemini_client, JSON_SCHEMA, @prompt_manager, current_risks_to_check, model: @config.get("model", "gemini-2.5-flash-preview-04-17"))
182
+ else
183
+ prompt = @prompt_manager.build_prompt(full_code_content, current_risks_to_check, JSON_SCHEMA)
184
+ ai_analysis_result = @gemini_client.analyze(prompt, JSON_SCHEMA, model: @config.get("model", "gemini-2.5-flash-preview-04-17"))
185
+ end
136
186
  end
187
+ else
188
+ puts "Unknown scan mode: #{@options[:scan_mode]}"
189
+ puts @opt_parser
190
+ return # Exit if scan mode is invalid
137
191
  end
138
192
 
139
193
  # Combine results and display report
140
- combined_results = combine_results(analysis_result, brakeman_result, bundler_audit_result)
194
+ ai_analysis_result ||= { "security_risks" => [] } # Ensure it's not nil
195
+ combined_results = combine_results(ai_analysis_result, brakeman_result, bundler_audit_result)
141
196
  display_report(combined_results)
142
197
 
143
198
  puts "Scan complete."
@@ -146,81 +201,69 @@ module Omamori
146
201
  generate_ci_setup(@options[:ci_service])
147
202
 
148
203
  when :init
149
- generate_config_file # Generate initial config file
204
+ generate_initial_files
150
205
 
151
206
  else
152
207
  puts "Unknown command: #{@options[:command]}"
153
- puts @opt_parser # Display help for unknown command
208
+ puts @opt_parser
154
209
  end
155
210
  end
156
211
 
157
212
  private
158
213
 
159
- # Combine AI analysis results and static analyser results
160
214
  def combine_results(ai_result, brakeman_result, bundler_audit_result)
161
- # Transform bundler_audit_result to match the expected structure in tests/formatters
162
215
  formatted_bundler_audit_result = if bundler_audit_result && bundler_audit_result["results"]
163
216
  { "scan" => { "results" => bundler_audit_result["results"] } }
164
217
  else
165
- # Return a structure that formatters can handle gracefully
166
- { "scan" => { "results" => [] } } # Or nil, depending on desired behavior when no results
218
+ { "scan" => { "results" => [] } }
167
219
  end
168
-
169
220
  combined = {
170
221
  "ai_security_risks" => ai_result && ai_result["security_risks"] ? ai_result["security_risks"] : [],
171
222
  "static_analysis_results" => {
172
223
  "brakeman" => brakeman_result,
173
- "bundler_audit" => formatted_bundler_audit_result # Use the transformed result
224
+ "bundler_audit" => formatted_bundler_audit_result
174
225
  }
175
226
  }
176
227
  combined
177
228
  end
178
229
 
179
- # Default risks to check if not specified in config
180
- DEFAULT_RISKS_TO_CHECK = [
181
- :xss, :csrf, :idor, :open_redirect, :ssrf, :session_fixation
182
- # TODO: Add other risks from requirements
183
- ].freeze
184
-
185
230
  def get_risks_to_check
186
- # Get risks to check from config, default to hardcoded list if not specified
187
- @config.get("checks", DEFAULT_RISKS_TO_CHECK)
231
+ # 設定ファイルからチェック対象のリスクを取得し、シンボルの配列に変換する
232
+ # 設定がない場合は DEFAULT_RISKS_TO_CHECK を使用する
233
+ configured_checks = @config.get("checks", DEFAULT_RISKS_TO_CHECK)
234
+ configured_checks.map(&:to_sym)
188
235
  end
189
236
 
190
237
  def parse_options
191
238
  @opt_parser = OptionParser.new do |opts|
192
- opts.banner = "Usage: omamori [command] [options]"
193
-
239
+ opts.banner = "Usage: omamori [command] [PATH...] [options]"
194
240
  opts.separator ""
195
241
  opts.separator "Commands:"
196
- opts.separator " scan [options] : Scan code or diff for security vulnerabilities"
197
- opts.separator " ci-setup [options] : Generate CI/CD setup files"
198
- opts.separator " init : Generate initial config file (.omamorirc)"
199
-
242
+ opts.separator " scan [PATH...] [options] : Scan specified files/directories or staged changes"
243
+ opts.separator " ci-setup [options] : Generate CI/CD setup files"
244
+ opts.separator " init : Generate initial config file (.omamorirc) and .omamoriignore"
200
245
  opts.separator ""
201
246
  opts.separator "Scan Options:"
202
- opts.on("--diff", "Scan only the staged differences (default)") do
203
- @options[:scan_mode] = :diff
247
+ opts.on("--diff", "Scan only the staged differences (default if no PATH is specified)") do
248
+ @options[:scan_mode_explicit] = :diff
204
249
  end
205
-
206
250
  opts.on("--all", "Scan the entire codebase") do
207
- @options[:scan_mode] = :all
251
+ @options[:scan_mode_explicit] = :all
208
252
  end
209
-
210
253
  opts.on("--format FORMAT", [:console, :html, :json], "Output format (console, html, json)") do |format|
211
254
  @options[:format] = format
212
255
  end
213
-
214
256
  opts.on("--ai", "Run only AI analysis, skipping static analysers") do
215
257
  @options[:only_ai] = true
216
258
  end
217
-
259
+ opts.on("--force-scan-ignored", "Force scan files and directories listed in .omamoriignore") do
260
+ @options[:force_scan_ignored] = true
261
+ end
218
262
  opts.separator ""
219
263
  opts.separator "CI Setup Options:"
220
264
  opts.on("--ci SERVICE", [:github_actions, :gitlab_ci], "Generate setup for specified CI service (github_actions, gitlab_ci)") do |service|
221
265
  @options[:ci_service] = service
222
266
  end
223
-
224
267
  opts.separator ""
225
268
  opts.separator "General Options:"
226
269
  opts.on("-h", "--help", "Prints this help") do
@@ -229,39 +272,127 @@ module Omamori
229
272
  end
230
273
  end
231
274
 
232
- # Determine command before parsing options
233
- # Use @args instead of ARGV
234
- command = @args.first.to_s.downcase.to_sym rescue nil
235
- if [:scan, :ci_setup, :init].include?(command)
236
- @options[:command] = @args.shift.to_sym # Consume the command argument from @args
275
+ command_candidate = @args.first.to_s.downcase.to_sym
276
+ if [:scan, :ci_setup, :init].include?(command_candidate)
277
+ @options[:command] = @args.shift.to_sym
237
278
  else
238
- @options[:command] = :scan # Default command is scan if not specified
279
+ @options[:command] = :scan # Default command
280
+ end
281
+
282
+ begin
283
+ @opt_parser.parse!(@args) # Parse remaining arguments for options
284
+ rescue OptionParser::InvalidOption => e
285
+ puts "Error: #{e.message}"
286
+ puts @opt_parser
287
+ exit 1
288
+ rescue OptionParser::MissingArgument => e
289
+ puts "Error: #{e.message}"
290
+ puts @opt_parser
291
+ exit 1
239
292
  end
240
293
 
241
- @opt_parser.parse!(@args)
242
294
 
243
- # Default scan mode to diff if command is scan and mode is not specified
244
- @options[:scan_mode] ||= :diff if @options[:command] == :scan
295
+ @target_paths = @args.dup
245
296
 
246
- # Display help if command is not recognized after parsing
247
- unless [:scan, :ci_setup, :init].include?(@options[:command])
248
- puts @opt_parser
249
- exit
297
+ # scan コマンドの場合の scan_mode の決定ロジック
298
+ if @options[:command] == :scan
299
+ if @options[:scan_mode_explicit]
300
+ # --diff または --all が明示的に指定された場合
301
+ @options[:scan_mode] = @options[:scan_mode_explicit]
302
+ # パス指定があり、かつ --all や --diff もある場合、パス指定を優先する
303
+ if !@target_paths.empty? && (@options[:scan_mode] == :all || @options[:scan_mode] == :diff)
304
+ puts "Warning: Paths provided with --#{@options[:scan_mode]}. Scanning specified paths instead of full codebase/diff."
305
+ @options[:scan_mode] = :paths
306
+ end
307
+ elsif !@target_paths.empty?
308
+ # パス指定があり、--diff や --all がない場合
309
+ @options[:scan_mode] = :paths
310
+ else
311
+ # パス指定がなく、--diff や --all もない場合 (例: omamori scan, omamori scan --ai)
312
+ @options[:scan_mode] = :diff # デフォルトは diff
313
+ end
250
314
  end
251
315
  end
252
316
 
317
+ def matches_ignore_pattern?(file_path, ignore_patterns, force_scan_ignored)
318
+ return false if force_scan_ignored # 強制スキャンが有効な場合は無視しない
319
+
320
+ # file_path をプロジェクトルートからの相対パスに正規化する
321
+ # Pathname を使用して堅牢なパス操作を行う
322
+ project_root = Pathname.pwd
323
+ absolute_file_path = Pathname.new(file_path).expand_path
324
+ relative_file_path = absolute_file_path.relative_path_from(project_root).to_s
325
+
326
+ ignore_patterns.each do |pattern|
327
+ negated = pattern.start_with?('!')
328
+ current_pattern = negated ? pattern[1..] : pattern
329
+
330
+ # パターンが '/' で終わる場合、ディレクトリ全体を対象とする
331
+ if current_pattern.end_with?('/')
332
+ # "dir/" のようなパターンは "dir/file.rb" や "dir/subdir/file.rb" にマッチする
333
+ # relative_file_path が current_pattern (末尾の '/' を除いたもの) で始まるか確認
334
+ if relative_file_path.start_with?(current_pattern.chomp('/')) &&
335
+ (relative_file_path.length == current_pattern.chomp('/').length || # ディレクトリ自体にマッチ (例: "dir" vs "dir/")
336
+ relative_file_path[current_pattern.chomp('/').length] == '/') # ディレクトリ内のファイルにマッチ
337
+ return !negated # マッチし、かつ否定パターンでなければ無視する
338
+ end
339
+ else
340
+ # ファイル名またはglobパターンにマッチするか確認
341
+ # File.fnmatch はシェルのglobのように動作する
342
+ # File::FNM_PATHNAME は '*' が '/' にマッチしないようにする
343
+ if File.fnmatch(current_pattern, relative_file_path, File::FNM_PATHNAME | File::FNM_DOTMATCH) || # FNM_DOTMATCH で隠しファイルも考慮
344
+ File.fnmatch(current_pattern, File.basename(relative_file_path), File::FNM_PATHNAME | File::FNM_DOTMATCH) # ファイル名のみでのマッチも考慮
345
+ return !negated # マッチし、かつ否定パターンでなければ無視する
346
+ end
347
+ end
348
+ end
349
+ false # どのパターンにもマッチしなければ無視しない
350
+ end
351
+
352
+ def collect_files_from_paths(target_paths, ignore_patterns, force_scan_ignored)
353
+ collected_files = []
354
+ target_paths.each do |path|
355
+ expanded_path = File.expand_path(path) # パスを絶対パスに展開
356
+ if File.file?(expanded_path)
357
+ # ファイルの場合、Rubyファイルであり、かつ無視パターンにマッチしないか確認
358
+ if File.extname(expanded_path) == '.rb' && !matches_ignore_pattern?(expanded_path, ignore_patterns, force_scan_ignored)
359
+ collected_files << expanded_path
360
+ end
361
+ elsif File.directory?(expanded_path)
362
+ # ディレクトリの場合、再帰的にRubyファイルを取得し、無視パターンを適用
363
+ Dir.glob(File.join(expanded_path, "**", "*.rb")).each do |file_path|
364
+ abs_file_path = File.expand_path(file_path) # globで見つかったパスも絶対パスに
365
+ if !matches_ignore_pattern?(abs_file_path, ignore_patterns, force_scan_ignored)
366
+ collected_files << abs_file_path
367
+ end
368
+ end
369
+ else
370
+ puts "Warning: Path not found or is not a file/directory: #{path}"
371
+ end
372
+ end
373
+ collected_files.uniq # 重複を除いて返す
374
+ end
375
+
253
376
  def get_staged_diff
254
377
  `git diff --staged`
255
378
  end
256
379
 
257
380
  def get_full_codebase
258
381
  code_content = ""
259
- # TODO: Get target directories/files from config
260
- Dir.glob("**/*.rb").each do |file_path|
261
- next if file_path.include?("vendor/") || file_path.include?(".git/") || file_path.include?(".cline/") # Exclude vendor, .git, and .cline directories
382
+ ignore_patterns = @config.ignore_patterns
383
+ force_scan_ignored = @options.fetch(:force_scan_ignored, false)
384
+ # カレントディレクトリ ('.') 内のRubyファイルを収集
385
+ files_to_scan = collect_files_from_paths(['.'], ignore_patterns, force_scan_ignored)
262
386
 
387
+ files_to_scan.each do |file_path|
263
388
  begin
264
- code_content += "# File: #{file_path}\n"
389
+ # 表示用に相対パスを試みるが、エラーなら絶対パスを使用
390
+ relative_display_path = begin
391
+ Pathname.new(file_path).relative_path_from(Pathname.pwd).to_s
392
+ rescue ArgumentError
393
+ file_path # fallback to absolute path
394
+ end
395
+ code_content += "# File: #{relative_display_path}\n"
265
396
  code_content += File.read(file_path)
266
397
  code_content += "\n\n"
267
398
  rescue => e
@@ -276,14 +407,12 @@ module Omamori
276
407
  when :console
277
408
  puts @console_formatter.format(combined_results)
278
409
  when :html
279
- # Get output file path from config/options
280
410
  report_config = @config.get("report", {})
281
411
  output_path_prefix = report_config.fetch("output_path", "./omamori_report")
282
412
  output_path = "#{output_path_prefix}.html"
283
413
  File.write(output_path, @html_formatter.format(combined_results))
284
414
  puts "HTML report generated: #{output_path}"
285
415
  when :json
286
- # Get output file path from config/options
287
416
  report_config = @config.get("report", {})
288
417
  output_path_prefix = report_config.fetch("output_path", "./omamori_report")
289
418
  output_path = "#{output_path_prefix}.json"
@@ -299,7 +428,8 @@ module Omamori
299
428
  when :gitlab_ci
300
429
  generate_gitlab_ci_workflow
301
430
  else
302
- puts "Unsupported CI service: #{ci_service}"
431
+ puts "Unsupported CI service: #{ci_service}. Supported: github_actions, gitlab_ci"
432
+ puts @opt_parser # ヘルプメッセージを表示
303
433
  end
304
434
  end
305
435
 
@@ -316,31 +446,46 @@ module Omamori
316
446
 
317
447
  steps:
318
448
  - name: Checkout code
319
- uses: actions/checkout@v4
449
+ uses: actions/checkout@v4 # Recommended to use specific version
320
450
 
321
451
  - name: Set up Ruby
322
- uses: ruby/setup-ruby@v1
452
+ uses: ruby/setup-ruby@v1 # Recommended to use specific version
323
453
  with:
324
- ruby-version: 2.7 # Or your project's Ruby version
454
+ ruby-version: '3.0' # Specify your project's Ruby version
325
455
 
326
456
  - name: Install dependencies
327
457
  run: bundle install
328
458
 
459
+ # Optional: Cache gems to speed up future builds
460
+ # - name: Cache gems
461
+ # uses: actions/cache@v3
462
+ # with:
463
+ # path: vendor/bundle
464
+ # key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }}
465
+ # restore-keys: |
466
+ # ${{ runner.os }}-gems-
467
+
329
468
  - name: Install Brakeman (if not in Gemfile)
330
- run: gem install brakeman || true # Install if not already present
469
+ run: gem install brakeman --no-document || true
331
470
 
332
471
  - name: Install Bundler-Audit (if not in Gemfile)
333
- run: gem install bundler-audit || true # Install if not already present
472
+ run: gem install bundler-audit --no-document || true
334
473
 
335
474
  - name: Run Omamori Scan
336
475
  env:
337
- GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} # Ensure you add GEMINI_API_KEY to GitHub Secrets
338
- run: bundle exec omamori scan --all --format console # Or --diff for diff scan
339
-
476
+ GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} # Ensure GEMINI_API_KEY is set in GitHub Secrets
477
+ # Example: Scan all files on push to main, diff on PRs
478
+ # This logic might need adjustment based on your workflow preference
479
+ run: |
480
+ if [ "$GITHUB_EVENT_NAME" == "pull_request" ]; then
481
+ bundle exec omamori scan --diff --format console
482
+ else
483
+ bundle exec omamori scan --all --format console
484
+ fi
340
485
  YAML
341
- # Get output file path from config/options, default to .github/workflows/omamori_scan.yml
342
486
  ci_config = @config.get("ci_setup", {})
343
487
  output_path = ci_config.fetch("github_actions_path", ".github/workflows/omamori_scan.yml")
488
+ FileUtils.mkdir_p(File.dirname(output_path)) # Ensure directory exists
344
489
  File.write(output_path, workflow_content)
345
490
  puts "GitHub Actions workflow generated: #{output_path}"
346
491
  end
@@ -353,79 +498,160 @@ module Omamori
353
498
 
354
499
  omamori_security_scan:
355
500
  stage: security_scan
356
- image: ruby:latest # Use a Ruby image
501
+ image: ruby:3.0 # Specify your project's Ruby version
502
+ # Cache gems
503
+ cache:
504
+ key:
505
+ files:
506
+ - Gemfile.lock
507
+ paths:
508
+ - vendor/bundle
357
509
  before_script:
358
- - apt-get update -qq && apt-get install -y nodejs # Install nodejs if needed for some tools
359
- - gem install bundler # Ensure bundler is installed
360
- - bundle install --jobs $(nproc) --retry 3 # Install dependencies
361
- - gem install brakeman || true # Install Brakeman if not in Gemfile
362
- - gem install bundler-audit || true # Install Bundler-Audit if not in Gemfile
510
+ - apt-get update -qq && apt-get install -y --no-install-recommends nodejs # If needed for JS runtime
511
+ - gem install bundler --no-document
512
+ - bundle install --jobs $(nproc) --retry 3 --path vendor/bundle
513
+ - gem install brakeman --no-document || true
514
+ - gem install bundler-audit --no-document || true
363
515
  script:
364
- - bundle exec omamori scan --all --format console # Or --diff for diff scan
516
+ # Example: Scan all files on pipelines for the default branch, diff on merge requests
517
+ - |
518
+ if [ "$CI_PIPELINE_SOURCE" == "merge_request_event" ]; then
519
+ bundle exec omamori scan --diff --format console
520
+ else
521
+ bundle exec omamori scan --all --format console
522
+ fi
365
523
  variables:
366
- GEMINI_API_KEY: $GEMINI_API_KEY # Ensure you set GEMINI_API_KEY as a CI/CD variable in GitLab
367
- # Optional: Define rules for when to run this job
368
- # rules:
369
- # - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
370
- # - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
371
-
524
+ GEMINI_API_KEY: $GEMINI_API_KEY # Set GEMINI_API_KEY as a CI/CD variable in GitLab
525
+ rules:
526
+ - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
527
+ - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
372
528
  YAML
373
- # Get output file path from config/options, default to .gitlab-ci.yml
374
529
  ci_config = @config.get("ci_setup", {})
375
530
  output_path = ci_config.fetch("gitlab_ci_path", ".gitlab-ci.yml")
376
531
  File.write(output_path, workflow_content)
377
532
  puts "GitLab CI workflow generated: #{output_path}"
378
533
  end
379
534
 
380
- def generate_config_file
535
+ DEFAULT_OMAMORIIGNORE_CONTENT = <<~IGNORE
536
+ # Omamori ignore file
537
+ # Add files and directories to ignore during Omamori scans.
538
+ # Lines starting with # are comments.
539
+ # Globs are supported (e.g., *.tmp, spec/fixtures/)
540
+ # Negation with ! (e.g., !important.log) - not fully implemented in current basic matcher
541
+
542
+ # Log files
543
+ log/
544
+ *.log
545
+
546
+ # Temporary files
547
+ tmp/
548
+ *.tmp
549
+ *.swp
550
+ *.swo
551
+
552
+ # OS-specific files
553
+ .DS_Store
554
+ Thumbs.db
555
+
556
+ # Vendor directory (often contains third-party code)
557
+ vendor/bundle/
558
+
559
+ # Coverage reports
560
+ coverage/
561
+
562
+ # Node.js dependencies
563
+ node_modules/
564
+
565
+ # Build artifacts
566
+ pkg/
567
+
568
+ # Test files and fixtures (optional, consider if they contain sensitive examples)
569
+ # spec/
570
+ # test/
571
+ # features/
572
+
573
+ # Database schema and migrations (usually not directly exploitable via code injection)
574
+ # db/schema.rb
575
+ # db/migrate/
576
+
577
+ # Assets (compiled or static, less likely to have Ruby vulnerabilities)
578
+ # app/assets/builds/
579
+ # public/assets/
580
+ IGNORE
581
+
582
+ def generate_initial_files
381
583
  config_content = <<~YAML
382
584
  # .omamorirc
383
585
  # Configuration file for omamori gem
384
586
 
385
587
  # Gemini API Key (required for AI analysis)
386
- # You can also set this via the GEMINI_API_KEY environment variable
387
- api_key: YOUR_GEMINI_API_KEY # Replace with your actual API key
588
+ # You can also set this via the GEMINI_API_KEY environment variable.
589
+ # Example: api_key: "YOUR_GEMINI_API_KEY_HERE"
590
+ api_key: YOUR_GEMINI_API_KEY
388
591
 
389
- # Gemini Model to use (optional, default: gemini-1.5-pro-latest)
390
- # model: gemini-1.5-flash-latest
592
+ # Gemini Model to use (optional, default: g emini-2.5-flash-preview-04-17)
593
+ # Example: model: "gemini-2.5-pro-preview-05-06"
594
+ # model: "gemini-2.5-flash-preview-04-17"
391
595
 
392
- # Security checks to enable (optional, default: all implemented checks)
596
+ # Security checks to enable (optional, default: all implemented checks).
597
+ # Provide a list of symbols. Example:
393
598
  # checks:
394
- # xss: true
395
- # csrf: true
396
- # idor: true
397
- # ...
599
+ # - xss
600
+ # - csrf
601
+ # - idor
602
+ # - open_redirect
603
+ # # Add other risk symbols from Omamori::AIAnalysisEngine::PromptManager::RISK_PROMPTS.keys
398
604
 
399
- # Custom prompt templates (optional)
605
+ # Custom prompt templates (optional).
400
606
  # prompt_templates:
401
607
  # default: |
402
- # Your custom prompt template here...
608
+ # Analyze the following Ruby code for security vulnerabilities.
609
+ # Focus on: %{risk_list}.
610
+ # Report in JSON format: %{json_schema}.
611
+ # Code:
612
+ # %{code_content}
403
613
 
404
- # Report output settings (optional)
614
+ # Report output settings (optional).
405
615
  # report:
406
- # output_path: ./omamori_report # Output directory/prefix for html/json reports
407
- # html_template: path/to/custom/template.erb # Custom HTML template
616
+ # output_path: "./omamori_scan_results" # Prefix for html/json reports
617
+ # html_template: "custom_report_template.erb" # Path to custom ERB template
408
618
 
409
- # Static analyser options (optional)
619
+ # Static analyser options (optional).
620
+ # Provide options as a hash.
410
621
  # static_analysers:
411
622
  # brakeman:
412
- # options: "--force" # Additional Brakeman options
623
+ # options: {"--skip-checks": "BasicAuth", "--no-progress": true}
413
624
  # bundler_audit:
414
- # options: "--quiet" # Additional Bundler-Audit options
415
-
416
- # Language setting for AI analysis details (optional, default: en)
417
- # language: ja
418
-
419
- YAML
420
- # TODO: Specify output file path from options
421
- output_path = Omamori::Config::DEFAULT_CONFIG_PATH
422
- if File.exist?(output_path)
423
- puts "Config file already exists at #{output_path}. Aborting init."
625
+ # options: {quiet: true}
626
+
627
+ # Language for AI analysis details (optional, default: "en").
628
+ # Supported languages depend on the AI model.
629
+ # language: "ja"
630
+
631
+ # Chunk size for splitting large code content for AI analysis (optional, default: 8000 characters).
632
+ # chunk_size: 10000
633
+
634
+ # CI setup file paths (optional).
635
+ # ci_setup:
636
+ # github_actions_path: ".github/workflows/custom_omamori_scan.yml"
637
+ # gitlab_ci_path: ".custom-gitlab-ci.yml"
638
+ YAML
639
+ config_output_path = Omamori::Config::DEFAULT_CONFIG_PATH
640
+ if File.exist?(config_output_path)
641
+ puts "Config file already exists at #{config_output_path}. Aborting .omamorirc generation."
642
+ else
643
+ File.write(config_output_path, config_content)
644
+ puts "Config file generated: #{config_output_path}"
645
+ puts "IMPORTANT: Please open #{config_output_path} and replace 'YOUR_GEMINI_API_KEY' with your actual Gemini API key."
646
+ end
647
+
648
+ ignore_output_path = Omamori::Config::DEFAULT_IGNORE_PATH
649
+ if File.exist?(ignore_output_path)
650
+ puts ".omamoriignore file already exists at #{ignore_output_path}. Aborting .omamoriignore generation."
424
651
  else
425
- File.write(output_path, config_content)
426
- puts "Config file generated: #{output_path}"
427
- puts "Please replace 'YOUR_GEMINI_API_KEY' with your actual API key."
652
+ File.write(ignore_output_path, DEFAULT_OMAMORIIGNORE_CONTENT)
653
+ puts ".omamoriignore file generated: #{ignore_output_path}"
428
654
  end
429
655
  end
430
656
  end
431
- end
657
+ end