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
@@ -1,93 +1,97 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'optparse'
4
- 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
4
+ require 'fileutils' # FileUtils を require する
5
+ require 'pathname' # Pathname を require する
6
+ require 'shellwords'
7
+ require_relative 'ai_analysis_engine/llm_client'
8
+ require_relative 'ai_settings'
9
+ require_relative 'setup_files'
10
+ require_relative 'doctor'
11
+ require_relative 'ai_analysis_engine/prompt_manager'
12
+ require_relative 'ai_analysis_engine/diff_splitter'
13
+ require_relative 'ai_analysis_engine/checklist_scanner'
14
+ require_relative 'report_generator/console_formatter'
15
+ require_relative 'report_generator/html_formatter'
16
+ require_relative 'report_generator/json_formatter'
17
+ require_relative 'static_analysers/brakeman_runner'
18
+ require_relative 'static_analysers/bundler_audit_runner'
19
+ require 'json'
20
+ require_relative 'config'
14
21
 
15
22
  module Omamori
16
23
  class CoreRunner
17
24
  # Define the JSON Schema for Structured Output
18
25
  JSON_SCHEMA = {
19
- "type": "object",
26
+ "type": 'object',
20
27
  "properties": {
21
28
  "security_risks": {
22
- "type": "array",
23
- "description": "検出されたセキュリティリスクのリスト。",
29
+ "type": 'array',
30
+ "description": '検出されたセキュリティリスクのリスト。',
24
31
  "items": {
25
- "type": "object",
32
+ "type": 'object',
26
33
  "properties": {
27
34
  "type": {
28
- "type": "string",
29
- "description": "検出されたリスクの種類 (例: XSS, CSRF, IDORなど)。3.3の診断対象脆弱性リストのいずれかであること。"
35
+ "type": 'string',
36
+ "description": '検出されたリスクの種類 (例: XSS, CSRF, IDORなど)。3.3の診断対象脆弱性リストのいずれかであること。'
30
37
  },
31
38
  "location": {
32
- "type": "string",
33
- "description": "リスクが存在するコードのファイル名、行番号、またはコードスニペット。差分分析の場合は差分の該当箇所を示す形式 (例: ファイル名:+行番号) であること。"
39
+ "type": 'string',
40
+ "description": 'リスクが存在するコードのファイル名、行番号、またはコードスニペット。差分分析の場合は差分の該当箇所を示す形式 (例: ファイル名:+行番号) であること。'
34
41
  },
35
42
  "details": {
36
- "type": "string",
37
- "description": "リスクの詳細な説明と、なぜそれがリスクなのかの理由。"
43
+ "type": 'string',
44
+ "description": 'リスクの詳細な説明と、なぜそれがリスクなのかの理由。'
38
45
  },
39
46
  "severity": {
40
- "type": "string",
41
- "description": "リスクの深刻度。",
42
- "enum": ["Critical", "High", "Medium", "Low", "Info"]
47
+ "type": 'string',
48
+ "description": 'リスクの深刻度。',
49
+ "enum": %w[Critical High Medium Low Info]
43
50
  },
44
51
  "code_snippet": {
45
- "type": "string",
46
- "description": "該当するコードスニペット。"
52
+ "type": 'string',
53
+ "description": '該当するコードスニペット。'
47
54
  }
48
55
  },
49
- "required": ["type", "location", "details", "severity"]
56
+ "required": %w[type location details severity]
50
57
  }
51
58
  }
52
59
  },
53
- "required": ["security_risks"]
60
+ "required": ['security_risks']
54
61
  }.freeze # Freeze the hash to make it immutable
55
62
 
56
- # TODO: Get risks to check from config file
57
- RISKS_TO_CHECK = [
58
- :xss, :csrf, :idor, :open_redirect, :ssrf, :session_fixation
59
- # TODO: Add other risks from requirements
60
- ].freeze
61
63
 
62
- # TODO: Determine threshold for splitting based on token limits
63
- SPLIT_THRESHOLD = 7000 # Characters as a proxy for tokens
64
+ DEFAULT_MODEL = AIAnalysisEngine::LLMClient::DEFAULT_MODEL
65
+
66
+ # Threshold for splitting large content (characters as a proxy for tokens)
67
+ # Can be overridden by config
68
+ DEFAULT_SPLIT_THRESHOLD = 8000 # Characters
64
69
 
65
70
  def initialize(args)
66
71
  @args = args
67
- @options = { command: :scan, format: :console } # Default command is scan, default format is console
68
- @config = Omamori::Config.new # Initialize Config
69
-
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
73
- @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
79
- report_config = @config.get("report", {})
80
- 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
86
- 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
72
+ @options = { command: :scan, format: :console } # Default command and format
73
+ @target_paths = []
74
+ @config = Omamori::Config.new
75
+
76
+ # Initialize components with configuration
77
+ @prompt_manager = AIAnalysisEngine::PromptManager.new(@config)
78
+
79
+ chunk_size = @config.get('chunk_size', DEFAULT_SPLIT_THRESHOLD)
80
+ @diff_splitter = AIAnalysisEngine::DiffSplitter.new(chunk_size: chunk_size)
81
+
82
+ report_config = @config.get('report', {})
83
+ report_output_path = report_config.fetch('output_path', './omamori_report')
84
+ html_template_path = report_config.fetch('html_template', nil)
85
+
86
+ @console_formatter = ReportGenerator::ConsoleFormatter.new
87
+ @html_formatter = ReportGenerator::HTMLFormatter.new(report_output_path, html_template_path)
88
+ @json_formatter = ReportGenerator::JSONFormatter.new(report_output_path)
89
+
90
+ static_analyser_config = @config.get('static_analysers', {})
91
+ brakeman_options = static_analyser_config.fetch('brakeman', {}).fetch('options', {})
92
+ bundler_audit_options = static_analyser_config.fetch('bundler_audit', {}).fetch('options', {})
93
+ @brakeman_runner = StaticAnalysers::BrakemanRunner.new(brakeman_options)
94
+ @bundler_audit_runner = StaticAnalysers::BundlerAuditRunner.new(bundler_audit_options)
91
95
  end
92
96
 
93
97
  def run
@@ -95,178 +99,437 @@ module Omamori
95
99
 
96
100
  case @options[:command]
97
101
  when :scan
98
- # Run static analysers first unless --ai option is specified
102
+ validate_ai_settings
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
+ elsif decision_engine?
130
+ ai_analysis_result = checklist_scanner.scan(read_units(files_to_scan), get_risks_to_check)
131
+ else
132
+ files_to_scan.each do |file_path|
133
+ file_content = File.read(file_path)
134
+ puts "Analyzing file: #{file_path}..." # スキャン中のファイルパスを表示
135
+ current_risks_to_check = get_risks_to_check
136
+ # @diff_splitterのインスタンス変数 @chunk_size を参照して比較
137
+ if file_content.length > @diff_splitter.instance_variable_get(:@chunk_size)
138
+ puts "File content exceeds threshold (#{@diff_splitter.instance_variable_get(:@chunk_size)} chars), splitting..."
139
+ file_ai_result = @diff_splitter.process_in_chunks(file_content, llm_client, JSON_SCHEMA,
140
+ @prompt_manager, current_risks_to_check, file_path: file_path, model: llm_model)
141
+ else
142
+ prompt = @prompt_manager.build_prompt(file_content, current_risks_to_check, JSON_SCHEMA,
143
+ file_path: file_path)
144
+ file_ai_result = llm_client.analyze(prompt, JSON_SCHEMA,
145
+ model: llm_model)
146
+ end
147
+ # Merge results
148
+ if file_ai_result && file_ai_result['security_risks']
149
+ ai_analysis_result['security_risks'].concat(file_ai_result['security_risks'])
150
+ end
151
+ rescue StandardError => e
152
+ puts "Error analyzing file #{file_path}: #{e.message}"
153
+ end
154
+ end
155
+ end
109
156
  when :diff
110
- diff_content = get_staged_diff
157
+ # Scan staged differences, or the branch diff when --base is given
158
+ diff_content = get_diff
111
159
  if diff_content.empty?
112
- 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"))
160
+ puts @options[:base] ? "No changes against #{@options[:base]} to scan." : 'No staged changes to scan.'
161
+ elsif decision_engine?
162
+ puts "Scanning differences (#{ai_settings.describe})..."
163
+ units = @diff_splitter.split_by_file(diff_content)
164
+ ai_analysis_result = checklist_scanner.scan(units, get_risks_to_check)
119
165
  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"))
166
+ puts 'Scanning differences with AI...'
167
+ current_risks_to_check = get_risks_to_check
168
+ # @diff_splitterのインスタンス変数 @chunk_size を参照して比較
169
+ if diff_content.length > @diff_splitter.instance_variable_get(:@chunk_size)
170
+ puts "Diff content exceeds threshold (#{@diff_splitter.instance_variable_get(:@chunk_size)} chars), splitting..."
171
+ ai_analysis_result = @diff_splitter.process_in_chunks(diff_content, llm_client, JSON_SCHEMA,
172
+ @prompt_manager, current_risks_to_check, model: llm_model)
173
+ else
174
+ prompt = @prompt_manager.build_prompt(diff_content, current_risks_to_check, JSON_SCHEMA)
175
+ ai_analysis_result = llm_client.analyze(prompt, JSON_SCHEMA,
176
+ model: llm_model)
177
+ end
122
178
  end
123
179
  when :all
124
- full_code_content = get_full_codebase
125
- if full_code_content.strip.empty?
126
- 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"))
180
+ # Scan entire codebase
181
+ if decision_engine?
182
+ puts "Scanning entire codebase (#{ai_settings.describe})..."
183
+ files_to_scan = collect_files_from_paths(['.'], @config.ignore_patterns,
184
+ @options.fetch(:force_scan_ignored, false))
185
+ ai_analysis_result = checklist_scanner.scan(read_units(files_to_scan), get_risks_to_check)
186
+ elsif (full_code_content = get_full_codebase).strip.empty?
187
+ puts 'No code found to scan.'
133
188
  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"))
189
+ puts 'Scanning entire codebase with AI...'
190
+ current_risks_to_check = get_risks_to_check
191
+ # @diff_splitterのインスタンス変数 @chunk_size を参照して比較
192
+ if full_code_content.length > @diff_splitter.instance_variable_get(:@chunk_size)
193
+ puts "Full code content exceeds threshold (#{@diff_splitter.instance_variable_get(:@chunk_size)} chars), splitting..."
194
+ ai_analysis_result = @diff_splitter.process_in_chunks(full_code_content, llm_client, JSON_SCHEMA,
195
+ @prompt_manager, current_risks_to_check, model: llm_model)
196
+ else
197
+ prompt = @prompt_manager.build_prompt(full_code_content, current_risks_to_check, JSON_SCHEMA)
198
+ ai_analysis_result = llm_client.analyze(prompt, JSON_SCHEMA,
199
+ model: llm_model)
200
+ end
136
201
  end
202
+ else
203
+ puts "Unknown scan mode: #{@options[:scan_mode]}"
204
+ puts @opt_parser
205
+ return # Exit if scan mode is invalid
137
206
  end
138
207
 
139
208
  # Combine results and display report
140
- combined_results = combine_results(analysis_result, brakeman_result, bundler_audit_result)
209
+ ai_analysis_result ||= { 'security_risks' => [] } # Ensure it's not nil
210
+ combined_results = combine_results(ai_analysis_result, brakeman_result, bundler_audit_result)
141
211
  display_report(combined_results)
142
212
 
143
- puts "Scan complete."
213
+ puts 'Scan complete.'
144
214
 
145
215
  when :ci_setup
216
+ validate_ai_settings
146
217
  generate_ci_setup(@options[:ci_service])
147
218
 
148
219
  when :init
149
- generate_config_file # Generate initial config file
220
+ generate_initial_files
221
+
222
+ when :doctor
223
+ validate_ai_settings
224
+ exit 1 unless Doctor.new(ai_settings, json_schema: JSON_SCHEMA).run
150
225
 
151
226
  else
152
227
  puts "Unknown command: #{@options[:command]}"
153
- puts @opt_parser # Display help for unknown command
228
+ puts @opt_parser
154
229
  end
155
230
  end
156
231
 
157
232
  private
158
233
 
159
- # Combine AI analysis results and static analyser results
160
234
  def combine_results(ai_result, brakeman_result, bundler_audit_result)
161
- # Transform bundler_audit_result to match the expected structure in tests/formatters
162
- formatted_bundler_audit_result = if bundler_audit_result && bundler_audit_result["results"]
163
- { "scan" => { "results" => bundler_audit_result["results"] } }
235
+ formatted_bundler_audit_result = if bundler_audit_result && bundler_audit_result['results']
236
+ { 'scan' => { 'results' => bundler_audit_result['results'] } }
164
237
  else
165
- # Return a structure that formatters can handle gracefully
166
- { "scan" => { "results" => [] } } # Or nil, depending on desired behavior when no results
238
+ { 'scan' => { 'results' => [] } }
167
239
  end
168
-
169
- combined = {
170
- "ai_security_risks" => ai_result && ai_result["security_risks"] ? ai_result["security_risks"] : [],
171
- "static_analysis_results" => {
172
- "brakeman" => brakeman_result,
173
- "bundler_audit" => formatted_bundler_audit_result # Use the transformed result
240
+ {
241
+ 'ai_security_risks' => ai_result && ai_result['security_risks'] ? ai_result['security_risks'] : [],
242
+ 'static_analysis_results' => {
243
+ 'brakeman' => brakeman_result,
244
+ 'bundler_audit' => formatted_bundler_audit_result
174
245
  }
175
246
  }
176
- combined
177
247
  end
178
248
 
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
249
  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)
250
+ # 設定ファイルからチェック対象のリスクを取得し、シンボルの配列に変換する
251
+ # 設定がない場合は全項目を使う(判定役なしでも、レポート役が全項目を一度に判定する)
252
+ configured_checks = @config.get('checks', AIAnalysisEngine::PromptManager::RISK_PROMPTS.keys).map(&:to_sym)
253
+ configured_checks.filter_map do |risk|
254
+ if (reason = AIAnalysisEngine::PromptManager::RETIRED_RISKS[risk])
255
+ puts "Note: '#{risk}' is no longer checked by omamori. #{reason}"
256
+ next
257
+ end
258
+ AIAnalysisEngine::PromptManager::RISK_ALIASES.fetch(risk, risk)
259
+ end.uniq
260
+ end
261
+
262
+ # Which classifier and reporter to use, from .omamorirc and the command line
263
+ def ai_settings
264
+ @ai_settings ||= AISettings.new(@config, ai_overrides)
265
+ end
266
+
267
+ def ai_overrides
268
+ @ai_overrides ||= {}
269
+ end
270
+
271
+ # Stops before any scanning when the classifier or reporter settings are wrong or missing
272
+ def validate_ai_settings
273
+ return if ai_settings.reporter?
274
+
275
+ puts AISettings.reporter_missing_message
276
+ exit 1
277
+ rescue AISettings::Error => e
278
+ puts "Configuration error: #{e.message}"
279
+ exit 1
280
+ end
281
+
282
+ # The LLM that writes reports (and checks everything itself when there is no classifier)
283
+ def llm_client
284
+ @llm_client ||= ai_settings.build_reporter_client
285
+ end
286
+
287
+ def llm_model
288
+ ai_settings.reporter_model
289
+ end
290
+
291
+ def decision_engine?
292
+ ai_settings.classifier?
293
+ end
294
+
295
+ def checklist_scanner
296
+ AIAnalysisEngine::ChecklistScanner.new(
297
+ decision_client: ai_settings.build_classifier_client,
298
+ llm_client: llm_client,
299
+ prompt_manager: @prompt_manager,
300
+ diff_splitter: @diff_splitter,
301
+ json_schema: JSON_SCHEMA,
302
+ model: llm_model,
303
+ threshold: ai_settings.threshold,
304
+ concurrency: ai_settings.classifier_concurrency,
305
+ report_concurrency: ai_settings.reporter_concurrency,
306
+ sub_checks: ai_settings.sub_checks?
307
+ )
308
+ end
309
+
310
+ def read_units(file_paths)
311
+ file_paths.filter_map do |file_path|
312
+ relative_path = Pathname.new(file_path).relative_path_from(Pathname.pwd).to_s
313
+ [relative_path, File.read(file_path)]
314
+ rescue StandardError => e
315
+ puts "Error reading file #{file_path}: #{e.message}"
316
+ nil
317
+ end
188
318
  end
189
319
 
190
320
  def parse_options
191
321
  @opt_parser = OptionParser.new do |opts|
192
- opts.banner = "Usage: omamori [command] [options]"
193
-
194
- opts.separator ""
195
- 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
-
200
- opts.separator ""
201
- opts.separator "Scan Options:"
202
- opts.on("--diff", "Scan only the staged differences (default)") do
203
- @options[:scan_mode] = :diff
322
+ opts.banner = 'Usage: omamori [command] [PATH...] [options]'
323
+ opts.separator ''
324
+ opts.separator 'Commands:'
325
+ opts.separator ' scan [PATH...] [options] : Scan specified files/directories or staged changes'
326
+ opts.separator ' init --reporter PROVIDER [options]'
327
+ opts.separator ' : Write .omamorirc and .omamoriignore (and a CI file with --ci)'
328
+ opts.separator ' doctor : Send each configured classifier/reporter a small request to check the setup'
329
+ opts.separator ' ci-setup --ci SERVICE : Generate CI/CD setup files from .omamorirc'
330
+ opts.separator ''
331
+ opts.separator 'Scan Options:'
332
+ opts.on('--diff', 'Scan only the staged differences (default if no PATH is specified)') do
333
+ @options[:scan_mode_explicit] = :diff
204
334
  end
205
-
206
- opts.on("--all", "Scan the entire codebase") do
207
- @options[:scan_mode] = :all
335
+ opts.on('--all', 'Scan the entire codebase') do
336
+ @options[:scan_mode_explicit] = :all
208
337
  end
209
-
210
- opts.on("--format FORMAT", [:console, :html, :json], "Output format (console, html, json)") do |format|
338
+ opts.on('--format FORMAT', %i[console html json], 'Output format (console, html, json)') do |format|
211
339
  @options[:format] = format
212
340
  end
213
-
214
- opts.on("--ai", "Run only AI analysis, skipping static analysers") do
341
+ opts.on('--ai', 'Run only AI analysis, skipping static analysers') do
215
342
  @options[:only_ai] = true
216
343
  end
217
-
218
- opts.separator ""
219
- opts.separator "CI Setup Options:"
220
- opts.on("--ci SERVICE", [:github_actions, :gitlab_ci], "Generate setup for specified CI service (github_actions, gitlab_ci)") do |service|
344
+ opts.on('--base REF', 'Scan the diff between REF and HEAD (e.g. main) instead of staged changes') do |ref|
345
+ @options[:base] = ref
346
+ @options[:scan_mode_explicit] ||= :diff
347
+ end
348
+ opts.on('--classifier PROVIDER',
349
+ 'Classifier that checks each risk as a yes/no question (typesafe, openai, ollama, or none)') do |provider|
350
+ ai_overrides[:classifier] = provider
351
+ end
352
+ opts.on('--classifier-model MODEL', 'Classifier model (e.g. jev-1.13.0, gpt-6-luna)') do |model|
353
+ ai_overrides[:classifier_model] = model
354
+ end
355
+ opts.on('--reporter PROVIDER',
356
+ 'LLM that writes reports: any RubyLLM provider (gemini, anthropic, openai, ...), ' \
357
+ 'or claude_code / codex to use a subscription CLI') do |provider|
358
+ ai_overrides[:reporter] = provider
359
+ end
360
+ opts.on('--reporter-model MODEL', 'Reporter model (e.g. gemini-3.8-flash, claude-sonnet-5-5, sonnet)') do |model|
361
+ ai_overrides[:reporter_model] = model
362
+ end
363
+ opts.on('--reporter-fallback PROVIDER',
364
+ 'Reporter asked only when the reporter refuses or fails (e.g. claude_code), or none') do |provider|
365
+ ai_overrides[:reporter_fallback] = provider
366
+ end
367
+ opts.on('--reporter-fallback-model MODEL', 'Model for the fallback reporter') do |model|
368
+ ai_overrides[:reporter_fallback_model] = model
369
+ end
370
+ # Older options, kept for compatibility
371
+ opts.on('--engine ENGINE', %w[llm gemini jev openai], '(Deprecated) Use --classifier') do |engine|
372
+ ai_overrides[:classifier] = AISettings::LEGACY_ENGINES.fetch(engine, 'none')
373
+ end
374
+ opts.on('--llm LLM', %w[api gemini claude codex], '(Deprecated) Use --reporter') do |llm|
375
+ ai_overrides[:reporter] = AISettings::LEGACY_LLMS[llm] if AISettings::LEGACY_LLMS.key?(llm)
376
+ end
377
+ opts.on('--force-scan-ignored', 'Force scan files and directories listed in .omamoriignore') do
378
+ @options[:force_scan_ignored] = true
379
+ end
380
+ opts.separator ''
381
+ opts.separator 'Init Options (also --classifier, --classifier-model, --reporter, --reporter-model, ...):'
382
+ opts.on('--language LANG', 'Language of the report details written to .omamorirc (e.g. ja)') do |language|
383
+ @options[:language] = language
384
+ end
385
+ opts.on('--force', 'Overwrite an existing .omamorirc / CI file') do
386
+ @options[:force] = true
387
+ end
388
+ opts.separator ''
389
+ opts.separator 'CI Setup Options:'
390
+ opts.on('--ci SERVICE', %i[github_actions gitlab_ci],
391
+ 'Generate setup for specified CI service (github_actions, gitlab_ci)') do |service|
221
392
  @options[:ci_service] = service
222
393
  end
223
-
224
- opts.separator ""
225
- opts.separator "General Options:"
226
- opts.on("-h", "--help", "Prints this help") do
394
+ opts.separator ''
395
+ opts.separator 'General Options:'
396
+ opts.on('-h', '--help', 'Prints this help') do
227
397
  puts opts
228
398
  exit
229
399
  end
230
400
  end
231
401
 
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
237
- else
238
- @options[:command] = :scan # Default command is scan if not specified
402
+ command_candidate = @args.first.to_s.downcase.tr('-', '_').to_sym
403
+ @options[:command] = if %i[scan ci_setup init doctor].include?(command_candidate)
404
+ @args.shift.tr('-', '_').to_sym
405
+ else
406
+ :scan # Default command
407
+ end
408
+
409
+ begin
410
+ @opt_parser.parse!(@args) # Parse remaining arguments for options
411
+ rescue OptionParser::InvalidOption => e
412
+ puts "Error: #{e.message}"
413
+ puts @opt_parser
414
+ exit 1
415
+ rescue OptionParser::MissingArgument => e
416
+ puts "Error: #{e.message}"
417
+ puts @opt_parser
418
+ exit 1
239
419
  end
240
420
 
241
- @opt_parser.parse!(@args)
421
+ @target_paths = @args.dup
242
422
 
243
- # Default scan mode to diff if command is scan and mode is not specified
244
- @options[:scan_mode] ||= :diff if @options[:command] == :scan
423
+ # scan コマンドの場合の scan_mode の決定ロジック
424
+ return unless @options[:command] == :scan
245
425
 
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
426
+ if @options[:scan_mode_explicit]
427
+ # --diff または --all が明示的に指定された場合
428
+ @options[:scan_mode] = @options[:scan_mode_explicit]
429
+ # パス指定があり、かつ --all や --diff もある場合、パス指定を優先する
430
+ if !@target_paths.empty? && %i[all diff].include?(@options[:scan_mode])
431
+ puts "Warning: Paths provided with --#{@options[:scan_mode]}. Scanning specified paths instead of full codebase/diff."
432
+ @options[:scan_mode] = :paths
433
+ end
434
+ elsif !@target_paths.empty?
435
+ # パス指定があり、--diff や --all がない場合
436
+ @options[:scan_mode] = :paths
437
+ else
438
+ # パス指定がなく、--diff や --all もない場合 (例: omamori scan, omamori scan --ai)
439
+ @options[:scan_mode] = :diff # デフォルトは diff
440
+ end
441
+ end
442
+
443
+ def matches_ignore_pattern?(file_path, ignore_patterns, force_scan_ignored)
444
+ return false if force_scan_ignored # 強制スキャンが有効な場合は無視しない
445
+
446
+ # file_path をプロジェクトルートからの相対パスに正規化する
447
+ # Pathname を使用して堅牢なパス操作を行う
448
+ project_root = Pathname.pwd
449
+ absolute_file_path = Pathname.new(file_path).expand_path
450
+ relative_file_path = absolute_file_path.relative_path_from(project_root).to_s
451
+
452
+ ignore_patterns.each do |pattern|
453
+ negated = pattern.start_with?('!')
454
+ current_pattern = negated ? pattern[1..] : pattern
455
+
456
+ # パターンが '/' で終わる場合、ディレクトリ全体を対象とする
457
+ if current_pattern.end_with?('/')
458
+ # "dir/" のようなパターンは "dir/file.rb" や "dir/subdir/file.rb" にマッチする
459
+ # relative_file_path が current_pattern (末尾の '/' を除いたもの) で始まるか確認
460
+ if relative_file_path.start_with?(current_pattern.chomp('/')) &&
461
+ (relative_file_path.length == current_pattern.chomp('/').length || # ディレクトリ自体にマッチ (例: "dir" vs "dir/")
462
+ relative_file_path[current_pattern.chomp('/').length] == '/') # ディレクトリ内のファイルにマッチ
463
+ return !negated # マッチし、かつ否定パターンでなければ無視する
464
+ end
465
+ elsif File.fnmatch(current_pattern, relative_file_path, File::FNM_PATHNAME | File::FNM_DOTMATCH) || # FNM_DOTMATCH で隠しファイルも考慮
466
+ File.fnmatch(current_pattern, File.basename(relative_file_path), File::FNM_PATHNAME | File::FNM_DOTMATCH)
467
+ # ファイル名またはglobパターンにマッチするか確認
468
+ # File.fnmatch はシェルのglobのように動作する
469
+ # File::FNM_PATHNAME は '*' が '/' にマッチしないようにする
470
+ return !negated # ファイル名のみでのマッチも考慮
471
+ end
472
+ end
473
+ false # どのパターンにもマッチしなければ無視しない
474
+ end
475
+
476
+ def collect_files_from_paths(target_paths, ignore_patterns, force_scan_ignored)
477
+ collected_files = []
478
+ target_paths.each do |path|
479
+ expanded_path = File.expand_path(path) # パスを絶対パスに展開
480
+ if File.file?(expanded_path)
481
+ # ファイルの場合、Rubyファイルであり、かつ無視パターンにマッチしないか確認
482
+ if File.extname(expanded_path) == '.rb' && !matches_ignore_pattern?(expanded_path, ignore_patterns,
483
+ force_scan_ignored)
484
+ collected_files << expanded_path
485
+ end
486
+ elsif File.directory?(expanded_path)
487
+ # ディレクトリの場合、再帰的にRubyファイルを取得し、無視パターンを適用
488
+ Dir.glob(File.join(expanded_path, '**', '*.rb')).each do |file_path|
489
+ abs_file_path = File.expand_path(file_path) # globで見つかったパスも絶対パスに
490
+ unless matches_ignore_pattern?(abs_file_path, ignore_patterns, force_scan_ignored)
491
+ collected_files << abs_file_path
492
+ end
493
+ end
494
+ else
495
+ puts "Warning: Path not found or is not a file/directory: #{path}"
496
+ end
250
497
  end
498
+ collected_files.uniq # 重複を除いて返す
499
+ end
500
+
501
+ def get_diff
502
+ @options[:base] ? get_branch_diff(@options[:base]) : get_staged_diff
251
503
  end
252
504
 
253
505
  def get_staged_diff
254
506
  `git diff --staged`
255
507
  end
256
508
 
509
+ # Changes on the current branch since it diverged from base (what a pull request contains)
510
+ def get_branch_diff(base)
511
+ `git diff #{Shellwords.escape("#{base}...HEAD")}`
512
+ end
513
+
257
514
  def get_full_codebase
258
- 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
262
-
263
- begin
264
- code_content += "# File: #{file_path}\n"
265
- code_content += File.read(file_path)
266
- code_content += "\n\n"
267
- rescue => e
268
- puts "Error reading file #{file_path}: #{e.message}"
515
+ code_content = ''
516
+ ignore_patterns = @config.ignore_patterns
517
+ force_scan_ignored = @options.fetch(:force_scan_ignored, false)
518
+ # カレントディレクトリ ('.') 内のRubyファイルを収集
519
+ files_to_scan = collect_files_from_paths(['.'], ignore_patterns, force_scan_ignored)
520
+
521
+ files_to_scan.each do |file_path|
522
+ # 表示用に相対パスを試みるが、エラーなら絶対パスを使用
523
+ relative_display_path = begin
524
+ Pathname.new(file_path).relative_path_from(Pathname.pwd).to_s
525
+ rescue ArgumentError
526
+ file_path # fallback to absolute path
269
527
  end
528
+ code_content += "# File: #{relative_display_path}\n"
529
+ code_content += File.read(file_path)
530
+ code_content += "\n\n"
531
+ rescue StandardError => e
532
+ puts "Error reading file #{file_path}: #{e.message}"
270
533
  end
271
534
  code_content
272
535
  end
@@ -276,156 +539,92 @@ module Omamori
276
539
  when :console
277
540
  puts @console_formatter.format(combined_results)
278
541
  when :html
279
- # Get output file path from config/options
280
- report_config = @config.get("report", {})
281
- output_path_prefix = report_config.fetch("output_path", "./omamori_report")
542
+ report_config = @config.get('report', {})
543
+ output_path_prefix = report_config.fetch('output_path', './omamori_report')
282
544
  output_path = "#{output_path_prefix}.html"
283
545
  File.write(output_path, @html_formatter.format(combined_results))
284
546
  puts "HTML report generated: #{output_path}"
285
547
  when :json
286
- # Get output file path from config/options
287
- report_config = @config.get("report", {})
288
- output_path_prefix = report_config.fetch("output_path", "./omamori_report")
548
+ report_config = @config.get('report', {})
549
+ output_path_prefix = report_config.fetch('output_path', './omamori_report')
289
550
  output_path = "#{output_path_prefix}.json"
290
551
  File.write(output_path, @json_formatter.format(combined_results))
291
552
  puts "JSON report generated: #{output_path}"
292
553
  end
293
554
  end
294
555
 
295
- def generate_ci_setup(ci_service)
556
+ def generate_ci_setup(ci_service, settings = ai_settings)
296
557
  case ci_service
297
558
  when :github_actions
298
- generate_github_actions_workflow
559
+ generate_github_actions_workflow(settings)
299
560
  when :gitlab_ci
300
- generate_gitlab_ci_workflow
561
+ generate_gitlab_ci_workflow(settings)
301
562
  else
302
- puts "Unsupported CI service: #{ci_service}"
563
+ puts "Unsupported CI service: #{ci_service}. Supported: github_actions, gitlab_ci"
564
+ puts @opt_parser # ヘルプメッセージを表示
303
565
  end
304
566
  end
305
567
 
306
- def generate_github_actions_workflow
307
- workflow_content = <<~YAML
308
- # .github/workflows/omamori_scan.yml
309
- name: Omamori Security Scan
310
-
311
- on: [push, pull_request]
312
-
313
- jobs:
314
- security_scan:
315
- runs-on: ubuntu-latest
316
-
317
- steps:
318
- - name: Checkout code
319
- uses: actions/checkout@v4
320
-
321
- - name: Set up Ruby
322
- uses: ruby/setup-ruby@v1
323
- with:
324
- ruby-version: 2.7 # Or your project's Ruby version
325
-
326
- - name: Install dependencies
327
- run: bundle install
328
-
329
- - name: Install Brakeman (if not in Gemfile)
330
- run: gem install brakeman || true # Install if not already present
331
-
332
- - name: Install Bundler-Audit (if not in Gemfile)
333
- run: gem install bundler-audit || true # Install if not already present
568
+ def generate_github_actions_workflow(settings = ai_settings)
569
+ path = @config.get('ci_setup', {}).fetch('github_actions_path', '.github/workflows/omamori_scan.yml')
570
+ write_setup_file(path, SetupFiles.github_actions(settings), 'GitHub Actions workflow')
571
+ puts "Register these as GitHub secrets: #{SetupFiles.secret_names(settings).join(', ')}" if SetupFiles.secret_names(settings).any?
572
+ puts "Note: #{SetupFiles::CLI_IN_CI_NOTE}" if SetupFiles.cli_providers(settings).any?
573
+ end
334
574
 
335
- - name: Run Omamori Scan
336
- 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
575
+ def generate_gitlab_ci_workflow(settings = ai_settings)
576
+ path = @config.get('ci_setup', {}).fetch('gitlab_ci_path', '.gitlab-ci.yml')
577
+ write_setup_file(path, SetupFiles.gitlab_ci(settings), 'GitLab CI workflow')
578
+ puts "Register these as masked CI/CD variables: #{SetupFiles.secret_names(settings).join(', ')}" if SetupFiles.secret_names(settings).any?
579
+ puts "Note: #{SetupFiles::CLI_IN_CI_NOTE}" if SetupFiles.cli_providers(settings).any?
580
+ end
339
581
 
340
- YAML
341
- # Get output file path from config/options, default to .github/workflows/omamori_scan.yml
342
- ci_config = @config.get("ci_setup", {})
343
- output_path = ci_config.fetch("github_actions_path", ".github/workflows/omamori_scan.yml")
344
- File.write(output_path, workflow_content)
345
- puts "GitHub Actions workflow generated: #{output_path}"
582
+ # Settings from the command line only, so `init` does not depend on an existing .omamorirc
583
+ EMPTY_CONFIG = Struct.new(:values) { def get(key, default = nil) = values.fetch(key.to_s, default) }.new({})
584
+
585
+ # `omamori init --reporter PROVIDER [--reporter-model MODEL] [--classifier PROVIDER] [--language LANG] [--ci SERVICE]`
586
+ def generate_initial_files
587
+ settings = init_settings
588
+ ok = write_setup_file(Omamori::Config::DEFAULT_CONFIG_PATH,
589
+ SetupFiles.omamorirc(settings, language: @options[:language]), 'Config file')
590
+ unless File.exist?(Omamori::Config::DEFAULT_IGNORE_PATH)
591
+ File.write(Omamori::Config::DEFAULT_IGNORE_PATH, SetupFiles::OMAMORIIGNORE)
592
+ puts ".omamoriignore file generated: #{Omamori::Config::DEFAULT_IGNORE_PATH}"
593
+ end
594
+ exit 1 unless ok
595
+ generate_ci_setup(@options[:ci_service], settings) if @options[:ci_service]
596
+
597
+ puts ''
598
+ puts 'Next steps:'
599
+ SetupFiles.credential_steps(settings).each { |step| puts " - #{step}" }
600
+ puts ' - Run `omamori doctor` to check the setup'
601
+ puts ' - Run `omamori scan --base main` to scan a branch'
346
602
  end
347
603
 
348
- def generate_gitlab_ci_workflow
349
- workflow_content = <<~YAML
350
- # .gitlab-ci.yml
351
- stages:
352
- - security_scan
353
-
354
- omamori_security_scan:
355
- stage: security_scan
356
- image: ruby:latest # Use a Ruby image
357
- 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
363
- script:
364
- - bundle exec omamori scan --all --format console # Or --diff for diff scan
365
- 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
-
372
- YAML
373
- # Get output file path from config/options, default to .gitlab-ci.yml
374
- ci_config = @config.get("ci_setup", {})
375
- output_path = ci_config.fetch("gitlab_ci_path", ".gitlab-ci.yml")
376
- File.write(output_path, workflow_content)
377
- puts "GitLab CI workflow generated: #{output_path}"
604
+ def init_settings
605
+ unless ai_overrides[:reporter]
606
+ puts AISettings.reporter_missing_message(command: 'init')
607
+ puts "Reporter providers: claude_code, codex, or a RubyLLM provider (#{RubyLLM::Provider.providers.keys.join(', ')})"
608
+ exit 1
609
+ end
610
+
611
+ AISettings.new(EMPTY_CONFIG, ai_overrides)
612
+ rescue AISettings::Error => e
613
+ puts "Configuration error: #{e.message}"
614
+ exit 1
378
615
  end
379
616
 
380
- def generate_config_file
381
- config_content = <<~YAML
382
- # .omamorirc
383
- # Configuration file for omamori gem
384
-
385
- # 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
388
-
389
- # Gemini Model to use (optional, default: gemini-1.5-pro-latest)
390
- # model: gemini-1.5-flash-latest
391
-
392
- # Security checks to enable (optional, default: all implemented checks)
393
- # checks:
394
- # xss: true
395
- # csrf: true
396
- # idor: true
397
- # ...
398
-
399
- # Custom prompt templates (optional)
400
- # prompt_templates:
401
- # default: |
402
- # Your custom prompt template here...
403
-
404
- # Report output settings (optional)
405
- # report:
406
- # output_path: ./omamori_report # Output directory/prefix for html/json reports
407
- # html_template: path/to/custom/template.erb # Custom HTML template
408
-
409
- # Static analyser options (optional)
410
- # static_analysers:
411
- # brakeman:
412
- # options: "--force" # Additional Brakeman options
413
- # 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."
424
- 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."
617
+ # Writes a file unless it exists (or --force). Returns false when it was left as it was.
618
+ def write_setup_file(path, content, label)
619
+ if File.exist?(path) && !@options[:force]
620
+ puts "#{label} already exists at #{path}. Use --force to overwrite it."
621
+ return false
428
622
  end
623
+
624
+ FileUtils.mkdir_p(File.dirname(path))
625
+ File.write(path, content)
626
+ puts "#{label} generated: #{path}"
627
+ true
429
628
  end
430
629
  end
431
- end
630
+ end