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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +34 -14
- data/README.md +202 -49
- data/README_ja.md +204 -46
- data/demo/ai_analysis_vulnerability.rb +3 -3
- data/demo/csrf_vulnerability.rb +1 -1
- data/demo/eval_vulnerability.rb +1 -1
- data/demo/idor_vulnerability.rb +2 -2
- data/demo/insecure_cookie_vulnerability.rb +1 -1
- data/demo/open_redirect_vulnerability.rb +1 -1
- data/demo/static_analysis_vulnerability.rb +1 -1
- data/demo/xss_vulnerability.rb +1 -1
- data/exe/omamori +2 -2
- data/lib/omamori/ai_analysis_engine/checklist_scanner.rb +143 -0
- data/lib/omamori/ai_analysis_engine/cli_clients/base.rb +43 -0
- data/lib/omamori/ai_analysis_engine/cli_clients/claude_code_client.rb +35 -0
- data/lib/omamori/ai_analysis_engine/cli_clients/codex_client.rb +47 -0
- data/lib/omamori/ai_analysis_engine/diff_splitter.rb +30 -22
- data/lib/omamori/ai_analysis_engine/fallback_client.rb +23 -0
- data/lib/omamori/ai_analysis_engine/judge_client.rb +45 -0
- data/lib/omamori/ai_analysis_engine/llm_client.rb +71 -0
- data/lib/omamori/ai_analysis_engine/prompt_manager.rb +96 -19
- data/lib/omamori/ai_analysis_engine/ruby_llm_context.rb +23 -0
- data/lib/omamori/ai_analysis_engine/sub_checks/audit_log_missing.yml +12 -0
- data/lib/omamori/ai_analysis_engine/sub_checks/csrf.yml +22 -0
- data/lib/omamori/ai_analysis_engine/sub_checks/insecure_deserialization_rce.yml +9 -0
- data/lib/omamori/ai_analysis_engine/sub_checks/insufficient_encryption.yml +19 -0
- data/lib/omamori/ai_analysis_engine/sub_checks/mass_assignment.yml +19 -0
- data/lib/omamori/ai_analysis_engine/sub_checks/open_redirect.yml +17 -0
- data/lib/omamori/ai_analysis_engine/sub_checks/sql_injection.yml +20 -0
- data/lib/omamori/ai_analysis_engine/sub_checks/xss.yml +37 -0
- data/lib/omamori/ai_settings.rb +273 -0
- data/lib/omamori/config.rb +89 -69
- data/lib/omamori/core_runner.rb +485 -286
- data/lib/omamori/doctor.rb +95 -0
- data/lib/omamori/parallel.rb +24 -0
- data/lib/omamori/report_generator/console_formatter.rb +48 -42
- data/lib/omamori/report_generator/html_formatter.rb +6 -6
- data/lib/omamori/report_generator/json_formatter.rb +1 -1
- data/lib/omamori/setup_files.rb +260 -0
- data/lib/omamori/static_analysers/brakeman_runner.rb +7 -7
- data/lib/omamori/static_analysers/bundler_audit_runner.rb +8 -6
- data/lib/omamori/version.rb +2 -2
- data/lib/omamori.rb +11 -11
- metadata +61 -14
- data/lib/omamori/ai_analysis_engine/gemini_client.rb +0 -71
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'ai_settings'
|
|
4
|
+
|
|
5
|
+
module Omamori
|
|
6
|
+
# `omamori doctor`: checks that the configured classifier and reporters can be reached, by sending each
|
|
7
|
+
# one small request, and says how to fix what is missing.
|
|
8
|
+
class Doctor
|
|
9
|
+
REPORTER_PROBE = 'This is a connectivity check by `omamori doctor`. There is no code to review. ' \
|
|
10
|
+
'Return {"security_risks": []}.'
|
|
11
|
+
CLASSIFIER_PROBE_STATE = "puts 'hello'"
|
|
12
|
+
CLASSIFIER_PROBE_QUESTIONS = { ruby: 'Is this Ruby code?' }.freeze
|
|
13
|
+
CLI_INSTALL = {
|
|
14
|
+
'claude_code' => 'Install Claude Code (npm install -g @anthropic-ai/claude-code) and log in with `claude`',
|
|
15
|
+
'codex' => 'Install the Codex CLI (npm install -g @openai/codex) and log in with `codex login`'
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def initialize(settings, json_schema:)
|
|
19
|
+
@settings = settings
|
|
20
|
+
@json_schema = json_schema
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Returns true when every role answered
|
|
24
|
+
def run
|
|
25
|
+
puts "Settings: #{@settings.describe}"
|
|
26
|
+
results = []
|
|
27
|
+
results << check('classifier', @settings.classifier) { check_classifier } if @settings.classifier?
|
|
28
|
+
results << check('reporter', @settings.reporter) { check_reporter(@settings.reporter) }
|
|
29
|
+
if @settings.reporter_fallback
|
|
30
|
+
results << check('reporter.fallback', @settings.reporter_fallback) { check_reporter(@settings.reporter_fallback) }
|
|
31
|
+
end
|
|
32
|
+
ok = results.all?
|
|
33
|
+
puts ok ? 'All checks passed. Try `omamori scan --base main`.' : 'Some checks failed. Fix them and run `omamori doctor` again.'
|
|
34
|
+
ok
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def check(role, role_settings)
|
|
40
|
+
label = "#{role} (#{[role_settings['provider'], role_settings['model']].compact.join('/')})"
|
|
41
|
+
puts "Checking #{label}..."
|
|
42
|
+
problem = yield
|
|
43
|
+
if problem
|
|
44
|
+
puts " NG: #{problem}"
|
|
45
|
+
false
|
|
46
|
+
else
|
|
47
|
+
puts ' OK'
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
puts " NG: #{e.class}: #{e.message}"
|
|
52
|
+
hint = credential_hint(role_settings)
|
|
53
|
+
puts " #{hint}" if hint
|
|
54
|
+
false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Returns nil when it works, or what is wrong
|
|
58
|
+
def check_classifier
|
|
59
|
+
answers = @settings.build_classifier_client.classify(CLASSIFIER_PROBE_STATE, CLASSIFIER_PROBE_QUESTIONS)
|
|
60
|
+
return nil unless answers.values.include?(nil)
|
|
61
|
+
|
|
62
|
+
"The classifier gave no answer. #{credential_hint(@settings.classifier)}".strip
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def check_reporter(role_settings)
|
|
66
|
+
provider = role_settings['provider']
|
|
67
|
+
if AISettings::CLI_REPORTERS.include?(provider)
|
|
68
|
+
command = role_settings['command'] || (provider == 'claude_code' ? 'claude' : 'codex')
|
|
69
|
+
return "'#{command}' was not found. #{CLI_INSTALL[provider]}" unless command_exists?(command)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
result = @settings.build_llm_client(role_settings).analyze(REPORTER_PROBE, @json_schema, model: role_settings['model'])
|
|
73
|
+
return nil if result
|
|
74
|
+
|
|
75
|
+
"The reporter gave no usable answer (see the warning above). #{credential_hint(role_settings)}".strip
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def credential_hint(role_settings)
|
|
79
|
+
provider = role_settings['provider']
|
|
80
|
+
return "#{CLI_INSTALL[provider]}." if CLI_INSTALL.key?(provider)
|
|
81
|
+
|
|
82
|
+
env = AISettings.api_key_env(provider)
|
|
83
|
+
return nil unless env
|
|
84
|
+
return nil if role_settings['api_key'] || !ENV[env].to_s.empty?
|
|
85
|
+
|
|
86
|
+
"#{env} is not set."
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def command_exists?(command)
|
|
90
|
+
return File.executable?(command) if command.include?(File::SEPARATOR)
|
|
91
|
+
|
|
92
|
+
ENV['PATH'].to_s.split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, command)) }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omamori
|
|
4
|
+
# Minimal thread pool: maps items with at most `concurrency` threads, keeping order.
|
|
5
|
+
module Parallel
|
|
6
|
+
def self.map(items, concurrency)
|
|
7
|
+
queue = Queue.new
|
|
8
|
+
items.each_with_index { |item, index| queue << [item, index] }
|
|
9
|
+
results = Array.new(items.size)
|
|
10
|
+
workers = Array.new([concurrency, items.size].min) do
|
|
11
|
+
Thread.new do
|
|
12
|
+
loop do
|
|
13
|
+
item, index = queue.pop(true)
|
|
14
|
+
results[index] = yield(item)
|
|
15
|
+
end
|
|
16
|
+
rescue ThreadError
|
|
17
|
+
# queue is empty
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
workers.each(&:join)
|
|
21
|
+
results
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -6,30 +6,30 @@ module Omamori
|
|
|
6
6
|
module ReportGenerator
|
|
7
7
|
class ConsoleFormatter
|
|
8
8
|
SEVERITY_COLORS = {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
'Critical' => :red,
|
|
10
|
+
'High' => :red,
|
|
11
|
+
'Medium' => :yellow,
|
|
12
|
+
'Low' => :blue,
|
|
13
|
+
'Info' => :green
|
|
14
14
|
}.freeze
|
|
15
15
|
|
|
16
16
|
def format(combined_results)
|
|
17
|
-
output =
|
|
17
|
+
output = ''
|
|
18
18
|
|
|
19
19
|
# Format AI Analysis Results
|
|
20
|
-
ai_risks = combined_results && combined_results[
|
|
20
|
+
ai_risks = combined_results && combined_results['ai_security_risks'] ? combined_results['ai_security_risks'] : []
|
|
21
21
|
if !ai_risks.empty?
|
|
22
22
|
output += "--- AI Analysis Results ---\n".colorize(:bold)
|
|
23
23
|
ai_risks.each do |risk|
|
|
24
|
-
severity_color = SEVERITY_COLORS[risk[
|
|
24
|
+
severity_color = SEVERITY_COLORS[risk['severity']] || :white
|
|
25
25
|
# Use "Unknown Type" if risk["type"] is nil
|
|
26
|
-
risk_type = risk[
|
|
26
|
+
risk_type = risk['type'] || 'Unknown Type'
|
|
27
27
|
output += " - Type: #{risk_type.colorize(severity_color)}\n"
|
|
28
|
-
output += " Severity: #{risk[
|
|
29
|
-
output += " Location: #{risk[
|
|
30
|
-
output += " Details: #{risk[
|
|
28
|
+
output += " Severity: #{risk['severity'].colorize(severity_color)}\n"
|
|
29
|
+
output += " Location: #{risk['location']}\n"
|
|
30
|
+
output += " Details: #{risk['details']}\n"
|
|
31
31
|
output += " Code Snippet:\n"
|
|
32
|
-
output += format_code_snippet(risk[
|
|
32
|
+
output += format_code_snippet(risk['code_snippet'])
|
|
33
33
|
output += "\n"
|
|
34
34
|
end
|
|
35
35
|
else
|
|
@@ -39,22 +39,22 @@ module Omamori
|
|
|
39
39
|
output += "\n"
|
|
40
40
|
|
|
41
41
|
# Format Static Analysis Results
|
|
42
|
-
static_results = combined_results && combined_results[
|
|
42
|
+
static_results = combined_results && combined_results['static_analysis_results'] ? combined_results['static_analysis_results'] : {}
|
|
43
43
|
output += "--- Static Analysis Results ---\n".colorize(:bold)
|
|
44
44
|
|
|
45
45
|
# Format Brakeman Results
|
|
46
|
-
brakeman_result = static_results[
|
|
46
|
+
brakeman_result = static_results['brakeman']
|
|
47
47
|
if brakeman_result
|
|
48
48
|
output += "Brakeman:\n".colorize(:underline)
|
|
49
|
-
if brakeman_result[
|
|
50
|
-
brakeman_result[
|
|
51
|
-
severity_color = SEVERITY_COLORS[warning[
|
|
52
|
-
output += " - Warning Type: #{warning[
|
|
53
|
-
output += " Message: #{warning[
|
|
54
|
-
output += " File: #{warning[
|
|
55
|
-
output += " Line: #{warning[
|
|
56
|
-
output += " Code: #{warning[
|
|
57
|
-
output += " Link: #{warning[
|
|
49
|
+
if brakeman_result['warnings'] && !brakeman_result['warnings'].empty?
|
|
50
|
+
brakeman_result['warnings'].each do |warning|
|
|
51
|
+
severity_color = SEVERITY_COLORS[warning['confidence']] || :white # Map Brakeman confidence to severity color
|
|
52
|
+
output += " - Warning Type: #{warning['warning_type'].colorize(severity_color)}\n"
|
|
53
|
+
output += " Message: #{warning['message']}\n"
|
|
54
|
+
output += " File: #{warning['file']}\n"
|
|
55
|
+
output += " Line: #{warning['line']}\n"
|
|
56
|
+
output += " Code: #{warning['code']}\n"
|
|
57
|
+
output += " Link: #{warning['link']}\n"
|
|
58
58
|
output += " \n"
|
|
59
59
|
end
|
|
60
60
|
else
|
|
@@ -66,27 +66,27 @@ module Omamori
|
|
|
66
66
|
output += "\n"
|
|
67
67
|
|
|
68
68
|
# Format Bundler-Audit Results
|
|
69
|
-
bundler_audit_result = static_results[
|
|
70
|
-
if bundler_audit_result && bundler_audit_result[
|
|
69
|
+
bundler_audit_result = static_results['bundler_audit']
|
|
70
|
+
if bundler_audit_result && bundler_audit_result['scan'] && bundler_audit_result['scan']['results']
|
|
71
71
|
output += "Bundler-Audit:\n".colorize(:underline)
|
|
72
|
-
scan_results = bundler_audit_result[
|
|
72
|
+
scan_results = bundler_audit_result['scan']['results']
|
|
73
73
|
|
|
74
74
|
# Format vulnerabilities (type "unpatched_gem")
|
|
75
|
-
vulnerabilities = scan_results.select { |result| result[
|
|
75
|
+
vulnerabilities = scan_results.select { |result| result['type'] == 'unpatched_gem' }
|
|
76
76
|
if !vulnerabilities.empty?
|
|
77
77
|
output += " Vulnerabilities:\n".colorize(:bold)
|
|
78
78
|
vulnerabilities.each do |vulnerability_entry|
|
|
79
|
-
advisory = vulnerability_entry[
|
|
80
|
-
gem_info = vulnerability_entry[
|
|
81
|
-
severity_color = SEVERITY_COLORS[advisory[
|
|
82
|
-
output += " - ID: #{advisory[
|
|
83
|
-
output += " Gem: #{gem_info[
|
|
84
|
-
output += " Title: #{advisory[
|
|
85
|
-
output += " URL: #{advisory[
|
|
86
|
-
output += " Criticality: #{advisory[
|
|
87
|
-
output += " Description: #{advisory[
|
|
88
|
-
output += " Patched Versions: #{advisory[
|
|
89
|
-
output += " Advisory Date: #{advisory[
|
|
79
|
+
advisory = vulnerability_entry['advisory']
|
|
80
|
+
gem_info = vulnerability_entry['gem']
|
|
81
|
+
severity_color = SEVERITY_COLORS[advisory['criticality']] || :white # Map criticality to severity color
|
|
82
|
+
output += " - ID: #{advisory['id'].colorize(severity_color)}\n"
|
|
83
|
+
output += " Gem: #{gem_info['name']} (#{gem_info['version']})\n" # Include version
|
|
84
|
+
output += " Title: #{advisory['title']}\n"
|
|
85
|
+
output += " URL: #{advisory['url']}\n"
|
|
86
|
+
output += " Criticality: #{advisory['criticality'].colorize(severity_color)}\n"
|
|
87
|
+
output += " Description: #{advisory['description']}\n"
|
|
88
|
+
output += " Patched Versions: #{advisory['patched_versions'].join(', ')}\n"
|
|
89
|
+
output += " Advisory Date: #{advisory['date']}\n" # Use "date" key from advisory
|
|
90
90
|
output += "\n"
|
|
91
91
|
end
|
|
92
92
|
else
|
|
@@ -107,8 +107,14 @@ module Omamori
|
|
|
107
107
|
|
|
108
108
|
# Add summary before scan complete
|
|
109
109
|
ai_risk_count = ai_risks.length
|
|
110
|
-
brakeman_warning_count = brakeman_result && brakeman_result[
|
|
111
|
-
bundler_audit_vulnerability_count = bundler_audit_result && bundler_audit_result[
|
|
110
|
+
brakeman_warning_count = brakeman_result && brakeman_result['warnings'] ? brakeman_result['warnings'].length : 0
|
|
111
|
+
bundler_audit_vulnerability_count = if bundler_audit_result && bundler_audit_result['scan'] && bundler_audit_result['scan']['results']
|
|
112
|
+
bundler_audit_result['scan']['results'].select do |result|
|
|
113
|
+
result['type'] == 'unpatched_gem'
|
|
114
|
+
end.length
|
|
115
|
+
else
|
|
116
|
+
0
|
|
117
|
+
end
|
|
112
118
|
|
|
113
119
|
summary_output = "--- Scan Summary ---\n".colorize(:bold)
|
|
114
120
|
summary_output += "AI Analysis: #{ai_risk_count} issues".colorize(ai_risk_count > 0 ? :red : :green) + "\n"
|
|
@@ -129,4 +135,4 @@ module Omamori
|
|
|
129
135
|
end
|
|
130
136
|
end
|
|
131
137
|
end
|
|
132
|
-
end
|
|
138
|
+
end
|
|
@@ -8,7 +8,7 @@ module Omamori
|
|
|
8
8
|
def initialize(output_path_prefix, template_path = nil)
|
|
9
9
|
@output_path_prefix = output_path_prefix
|
|
10
10
|
# Use provided template_path if not nil, otherwise use default
|
|
11
|
-
@template_path = template_path || File.join(__dir__,
|
|
11
|
+
@template_path = template_path || File.join(__dir__, 'report_template.erb')
|
|
12
12
|
@template = ERB.new(File.read(@template_path))
|
|
13
13
|
rescue Errno::ENOENT
|
|
14
14
|
raise "HTML template file not found at #{@template_path}" # Raise error if template is not found
|
|
@@ -16,16 +16,16 @@ module Omamori
|
|
|
16
16
|
|
|
17
17
|
def format(combined_results)
|
|
18
18
|
# Prepare data for the template
|
|
19
|
-
@ai_risks = combined_results && combined_results[
|
|
20
|
-
@static_results = combined_results && combined_results[
|
|
19
|
+
@ai_risks = combined_results && combined_results['ai_security_risks'] ? combined_results['ai_security_risks'] : []
|
|
20
|
+
@static_results = combined_results && combined_results['static_analysis_results'] ? combined_results['static_analysis_results'] : {}
|
|
21
21
|
|
|
22
22
|
# Render the template
|
|
23
23
|
@template.result(binding)
|
|
24
24
|
rescue Errno::ENOENT
|
|
25
|
-
|
|
26
|
-
rescue => e
|
|
25
|
+
'Error: HTML template file not found.'
|
|
26
|
+
rescue StandardError => e
|
|
27
27
|
"Error generating HTML report: #{e.message}"
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
|
-
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'ai_settings'
|
|
4
|
+
|
|
5
|
+
module Omamori
|
|
6
|
+
# The files `omamori init` and `omamori ci-setup` write. Provider names are not listed here:
|
|
7
|
+
# whatever AISettings accepts (any RubyLLM provider, claude_code, codex) is written as given.
|
|
8
|
+
module SetupFiles
|
|
9
|
+
OMAMORIIGNORE = <<~IGNORE
|
|
10
|
+
# Omamori ignore file
|
|
11
|
+
# Add files and directories to ignore during Omamori scans.
|
|
12
|
+
# Lines starting with # are comments.
|
|
13
|
+
# Globs are supported (e.g., *.tmp, spec/fixtures/)
|
|
14
|
+
# Negation with ! (e.g., !important.log) - not fully implemented in current basic matcher
|
|
15
|
+
|
|
16
|
+
# Log files
|
|
17
|
+
log/
|
|
18
|
+
*.log
|
|
19
|
+
|
|
20
|
+
# Temporary files
|
|
21
|
+
tmp/
|
|
22
|
+
*.tmp
|
|
23
|
+
*.swp
|
|
24
|
+
*.swo
|
|
25
|
+
|
|
26
|
+
# OS-specific files
|
|
27
|
+
.DS_Store
|
|
28
|
+
Thumbs.db
|
|
29
|
+
|
|
30
|
+
# Vendor directory (often contains third-party code)
|
|
31
|
+
vendor/bundle/
|
|
32
|
+
|
|
33
|
+
# Coverage reports
|
|
34
|
+
coverage/
|
|
35
|
+
|
|
36
|
+
# Node.js dependencies
|
|
37
|
+
node_modules/
|
|
38
|
+
|
|
39
|
+
# Build artifacts
|
|
40
|
+
pkg/
|
|
41
|
+
|
|
42
|
+
# Test files and fixtures (optional, consider if they contain sensitive examples)
|
|
43
|
+
# spec/
|
|
44
|
+
# test/
|
|
45
|
+
# features/
|
|
46
|
+
|
|
47
|
+
# Database schema and migrations (usually not directly exploitable via code injection)
|
|
48
|
+
# db/schema.rb
|
|
49
|
+
# db/migrate/
|
|
50
|
+
|
|
51
|
+
# Assets (compiled or static, less likely to have Ruby vulnerabilities)
|
|
52
|
+
# app/assets/builds/
|
|
53
|
+
# public/assets/
|
|
54
|
+
IGNORE
|
|
55
|
+
|
|
56
|
+
# claude_code / codex use the CLI's login on the machine. omamori does not install or log in CLIs on
|
|
57
|
+
# CI runners: their packages and login methods are theirs to change.
|
|
58
|
+
CLI_IN_CI_NOTE = 'claude_code / codex need the CLI installed and logged in on the runner, which this ' \
|
|
59
|
+
'configuration does not do. Set that up yourself, or use an API reporter in CI ' \
|
|
60
|
+
'(add --reporter PROVIDER --reporter-model MODEL to the scan command).'
|
|
61
|
+
|
|
62
|
+
module_function
|
|
63
|
+
|
|
64
|
+
# settings: a resolved AISettings. language: e.g. "ja", or nil for the default (en)
|
|
65
|
+
def omamorirc(settings, language: nil)
|
|
66
|
+
<<~YAML
|
|
67
|
+
# .omamorirc
|
|
68
|
+
# Generated by `omamori init`. Check the setup with `omamori doctor`.
|
|
69
|
+
|
|
70
|
+
# Reporter: the LLM that writes reports. Any RubyLLM provider (with a model),
|
|
71
|
+
# or claude_code / codex to use a subscription CLI (claude -p / codex exec).
|
|
72
|
+
#{role_yaml('reporter', settings.reporter.merge(fallback_hash(settings)))}
|
|
73
|
+
# concurrency: 8 # Reports written in parallel (default 3 for CLIs, 8 for APIs)
|
|
74
|
+
|
|
75
|
+
# Classifier (optional): a fast decision model that checks each risk as a yes/no question.
|
|
76
|
+
# Only the flagged checks are sent to the reporter. Without it, the reporter checks everything.
|
|
77
|
+
#{classifier_yaml(settings)}
|
|
78
|
+
|
|
79
|
+
#{api_key_comment(settings)}
|
|
80
|
+
|
|
81
|
+
# Language of the report details (default: en)
|
|
82
|
+
#{language ? "language: #{language}" : '# language: ja'}
|
|
83
|
+
|
|
84
|
+
# Security checks to run (optional; default: the 8 main checks without a classifier, all 35 with one)
|
|
85
|
+
# checks:
|
|
86
|
+
# - xss
|
|
87
|
+
# - csrf
|
|
88
|
+
# - idor
|
|
89
|
+
|
|
90
|
+
# Report output settings (optional)
|
|
91
|
+
# report:
|
|
92
|
+
# output_path: ./omamori_report # Prefix for html/json reports
|
|
93
|
+
# html_template: custom_report_template.erb
|
|
94
|
+
|
|
95
|
+
# Static analyser options (optional)
|
|
96
|
+
# static_analysers:
|
|
97
|
+
# brakeman:
|
|
98
|
+
# options: "--force"
|
|
99
|
+
# bundler_audit:
|
|
100
|
+
# options: "--quiet"
|
|
101
|
+
|
|
102
|
+
# Chunk size for splitting large code content (optional, default: 8000 characters)
|
|
103
|
+
# chunk_size: 10000
|
|
104
|
+
|
|
105
|
+
# CI setup file paths (optional)
|
|
106
|
+
# ci_setup:
|
|
107
|
+
# github_actions_path: .github/workflows/omamori_scan.yml
|
|
108
|
+
# gitlab_ci_path: .gitlab-ci.yml
|
|
109
|
+
YAML
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# What the user has to do outside omamori before scanning: one line per role
|
|
113
|
+
def credential_steps(settings)
|
|
114
|
+
roles(settings).map do |role, role_settings|
|
|
115
|
+
provider = role_settings['provider']
|
|
116
|
+
case provider
|
|
117
|
+
when 'claude_code' then "#{role}: install Claude Code and log in (`claude`), or set ANTHROPIC_API_KEY"
|
|
118
|
+
when 'codex' then "#{role}: install the Codex CLI and log in (`codex login`)"
|
|
119
|
+
else
|
|
120
|
+
env = AISettings.api_key_env(provider)
|
|
121
|
+
env ? "#{role}: set #{env}" : "#{role}: set what RubyLLM needs for #{provider} (https://rubyllm.com)"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def github_actions(settings)
|
|
127
|
+
<<~YAML
|
|
128
|
+
# .github/workflows/omamori_scan.yml
|
|
129
|
+
name: Omamori Security Scan
|
|
130
|
+
|
|
131
|
+
on:
|
|
132
|
+
push:
|
|
133
|
+
branches: [ main, master ]
|
|
134
|
+
pull_request:
|
|
135
|
+
branches: [ main, master ]
|
|
136
|
+
|
|
137
|
+
jobs:
|
|
138
|
+
security_scan:
|
|
139
|
+
runs-on: ubuntu-latest
|
|
140
|
+
|
|
141
|
+
steps:
|
|
142
|
+
- name: Checkout code
|
|
143
|
+
uses: actions/checkout@v4
|
|
144
|
+
with:
|
|
145
|
+
fetch-depth: 0 # Needed to diff against the base branch
|
|
146
|
+
|
|
147
|
+
- name: Set up Ruby
|
|
148
|
+
uses: ruby/setup-ruby@v1 # Reads .ruby-version
|
|
149
|
+
with:
|
|
150
|
+
bundler-cache: true
|
|
151
|
+
#{cli_note(settings, ' ')}
|
|
152
|
+
- name: Run Omamori Scan
|
|
153
|
+
env:
|
|
154
|
+
#{env_lines(settings, indent: ' ') { |name| " #{name}: ${{ secrets.#{name} }}" }}
|
|
155
|
+
run: |
|
|
156
|
+
if [ "$GITHUB_EVENT_NAME" == "pull_request" ]; then
|
|
157
|
+
bundle exec omamori scan --base "origin/$GITHUB_BASE_REF" --format console
|
|
158
|
+
else
|
|
159
|
+
bundle exec omamori scan --all --format console
|
|
160
|
+
fi
|
|
161
|
+
YAML
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def gitlab_ci(settings)
|
|
165
|
+
<<~YAML
|
|
166
|
+
# .gitlab-ci.yml
|
|
167
|
+
stages:
|
|
168
|
+
- security_scan
|
|
169
|
+
|
|
170
|
+
omamori_security_scan:
|
|
171
|
+
stage: security_scan
|
|
172
|
+
image: ruby:3.3 # Use your project's Ruby version
|
|
173
|
+
cache:
|
|
174
|
+
key:
|
|
175
|
+
files:
|
|
176
|
+
- Gemfile.lock
|
|
177
|
+
paths:
|
|
178
|
+
- vendor/bundle
|
|
179
|
+
before_script:
|
|
180
|
+
- bundle config set --local path vendor/bundle
|
|
181
|
+
- bundle install --jobs $(nproc) --retry 3
|
|
182
|
+
#{cli_note(settings, ' ')}
|
|
183
|
+
script:
|
|
184
|
+
- |
|
|
185
|
+
if [ "$CI_PIPELINE_SOURCE" == "merge_request_event" ]; then
|
|
186
|
+
bundle exec omamori scan --base "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" --format console
|
|
187
|
+
else
|
|
188
|
+
bundle exec omamori scan --all --format console
|
|
189
|
+
fi
|
|
190
|
+
# Set these as masked CI/CD variables in GitLab:
|
|
191
|
+
#{env_lines(settings, indent: ' ') { |name| " # #{name}" }}
|
|
192
|
+
variables:
|
|
193
|
+
GIT_DEPTH: "0" # Needed to diff against the target branch
|
|
194
|
+
rules:
|
|
195
|
+
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
|
196
|
+
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
|
197
|
+
YAML
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# [[role name, settings], ...] for the classifier, reporter and fallback reporter in use
|
|
201
|
+
def roles(settings)
|
|
202
|
+
[['classifier', settings.classifier], ['reporter', settings.reporter],
|
|
203
|
+
['reporter.fallback', settings.reporter_fallback]].select { |_, role_settings| role_settings }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def role_yaml(name, role_settings)
|
|
207
|
+
lines = ["#{name}:"]
|
|
208
|
+
role_settings.each do |key, value|
|
|
209
|
+
if value.is_a?(Hash)
|
|
210
|
+
lines << " #{key}:"
|
|
211
|
+
value.each { |sub_key, sub_value| lines << " #{sub_key}: #{sub_value}" }
|
|
212
|
+
else
|
|
213
|
+
lines << " #{key}: #{value}"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
lines.join("\n")
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def classifier_yaml(settings)
|
|
220
|
+
threshold = " # threshold: #{AISettings::DEFAULT_THRESHOLD} # Checks at or above this probability go to the reporter"
|
|
221
|
+
return "#{role_yaml('classifier', settings.classifier)}\n#{threshold}" if settings.classifier?
|
|
222
|
+
|
|
223
|
+
"# classifier:\n# provider: typesafe # typesafe / openai / ollama\n# model: jev-1.13.0\n##{threshold[1..]}"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def fallback_hash(settings)
|
|
227
|
+
settings.reporter_fallback ? { 'fallback' => settings.reporter_fallback } : {}
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def api_key_comment(settings)
|
|
231
|
+
names = roles(settings).filter_map { |_, role_settings| AISettings.api_key_env(role_settings['provider']) }.uniq
|
|
232
|
+
return '# API keys are read from environment variables (PROVIDER_API_KEY, e.g. ANTHROPIC_API_KEY).' if names.empty?
|
|
233
|
+
|
|
234
|
+
"# API keys are read from environment variables: #{names.join(', ')}"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def cli_providers(settings)
|
|
238
|
+
roles(settings).map { |_, role_settings| role_settings['provider'] } & AISettings::CLI_REPORTERS
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# API key environment variables the scan step needs
|
|
242
|
+
def secret_names(settings)
|
|
243
|
+
roles(settings).filter_map { |_, role_settings| AISettings.api_key_env(role_settings['provider']) }.uniq
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def env_lines(settings, indent:, &format)
|
|
247
|
+
names = secret_names(settings)
|
|
248
|
+
return "#{indent}# Add the environment variables RubyLLM needs for your provider" if names.empty?
|
|
249
|
+
|
|
250
|
+
names.map(&format).join("\n")
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# A comment in the CI file when a role uses a subscription CLI
|
|
254
|
+
def cli_note(settings, indent)
|
|
255
|
+
return '' if cli_providers(settings).empty?
|
|
256
|
+
|
|
257
|
+
"#{indent}# #{CLI_IN_CI_NOTE}"
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -8,7 +8,7 @@ module Omamori
|
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def run
|
|
11
|
-
puts
|
|
11
|
+
puts 'Running Brakeman...'
|
|
12
12
|
# Determine Brakeman command based on options
|
|
13
13
|
# Use --force to run scan even if it's not a Rails application
|
|
14
14
|
# Use -f json for JSON output
|
|
@@ -18,11 +18,11 @@ module Omamori
|
|
|
18
18
|
if value.is_a?(TrueClass)
|
|
19
19
|
key.to_s
|
|
20
20
|
elsif value.is_a?(FalseClass)
|
|
21
|
-
|
|
21
|
+
'' # Don't include false flags
|
|
22
22
|
else
|
|
23
23
|
"#{key} #{value}"
|
|
24
24
|
end
|
|
25
|
-
end.join(
|
|
25
|
+
end.join(' ').strip
|
|
26
26
|
|
|
27
27
|
brakeman_command = "brakeman -f json . --force #{options_string}".strip # strip again in case options_string is empty
|
|
28
28
|
|
|
@@ -35,17 +35,17 @@ module Omamori
|
|
|
35
35
|
# the test setup might be causing it to be called multiple times or the mock is misconfigured.
|
|
36
36
|
JSON.parse(brakeman_output)
|
|
37
37
|
rescue Errno::ENOENT
|
|
38
|
-
puts
|
|
38
|
+
puts 'Error: Brakeman command not found. Is Brakeman installed?'
|
|
39
39
|
nil
|
|
40
40
|
rescue JSON::ParserError
|
|
41
|
-
puts
|
|
41
|
+
puts 'Error: Failed to parse Brakeman JSON output.'
|
|
42
42
|
puts "Raw output:\n#{brakeman_output}"
|
|
43
43
|
nil
|
|
44
|
-
rescue => e
|
|
44
|
+
rescue StandardError => e
|
|
45
45
|
puts "An error occurred during Brakeman execution: #{e.message}"
|
|
46
46
|
nil
|
|
47
47
|
end
|
|
48
48
|
end
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
|
-
end
|
|
51
|
+
end
|
|
@@ -14,7 +14,9 @@ module Omamori
|
|
|
14
14
|
# Example: bundle audit --format json
|
|
15
15
|
# Include options passed during initialization
|
|
16
16
|
# Build options string from the options hash
|
|
17
|
-
options_string = @options.map
|
|
17
|
+
options_string = @options.map do |key, value|
|
|
18
|
+
"--#{key.to_s.gsub('_', '-')}" + (value == true ? '' : " #{value}")
|
|
19
|
+
end.join(' ')
|
|
18
20
|
bundler_audit_command = "bundle audit --format json#{options_string.empty? ? '' : " #{options_string}"}"
|
|
19
21
|
|
|
20
22
|
begin
|
|
@@ -32,22 +34,22 @@ module Omamori
|
|
|
32
34
|
# Return the entire parsed output hash
|
|
33
35
|
parsed_output
|
|
34
36
|
else
|
|
35
|
-
puts
|
|
37
|
+
puts 'Error: Unexpected Bundler-Audit JSON output structure.'
|
|
36
38
|
puts "Raw output:\n#{bundler_audit_output}"
|
|
37
39
|
nil # Return nil if the structure is unexpected
|
|
38
40
|
end
|
|
39
41
|
rescue Errno::ENOENT
|
|
40
|
-
puts
|
|
42
|
+
puts 'Error: bundle command not found. Is Bundler installed?'
|
|
41
43
|
nil
|
|
42
44
|
rescue JSON::ParserError
|
|
43
|
-
puts
|
|
45
|
+
puts 'Error: Failed to parse Bundler-Audit JSON output.'
|
|
44
46
|
puts "Raw output:\n#{bundler_audit_output}"
|
|
45
47
|
nil
|
|
46
|
-
rescue => e
|
|
48
|
+
rescue StandardError => e
|
|
47
49
|
puts "An error occurred during Bundler-Audit execution: #{e.message}"
|
|
48
50
|
nil
|
|
49
51
|
end
|
|
50
52
|
end
|
|
51
53
|
end
|
|
52
54
|
end
|
|
53
|
-
end
|
|
55
|
+
end
|
data/lib/omamori/version.rb
CHANGED