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,19 @@
|
|
|
1
|
+
# Concrete patterns that are each enough to make insufficient encryption real on their own.
|
|
2
|
+
# Each question asks for the pattern AND a missing defense, so that code which uses the same construct safely is a no.
|
|
3
|
+
weak_randomness: >-
|
|
4
|
+
Does the code generate a token, reset code, one-time code, session id, or key with a predictable random source
|
|
5
|
+
(rand, Random.new, Kernel#srand, or values derived from the time or an id) instead of SecureRandom? Answer no when
|
|
6
|
+
SecureRandom (or a library that uses it) produces the value.
|
|
7
|
+
weak_cipher_use: >-
|
|
8
|
+
Does the code encrypt data with a fixed, reused, or derived-from-data IV or nonce, with ECB mode, with a hard-coded
|
|
9
|
+
key, or with an unauthenticated mode where tampering matters (for example AES-CBC without a MAC)? Answer no when it
|
|
10
|
+
uses ActiveSupport::MessageEncryptor or Active Record encryption with their defaults, or an authenticated mode such
|
|
11
|
+
as AES-GCM with a fresh random IV for every message.
|
|
12
|
+
encryption_removed: >-
|
|
13
|
+
Does the change stop encrypting sensitive data that was encrypted before, for example removing encrypts from a model
|
|
14
|
+
attribute, storing a secret or personal data in a plain column, or replacing encryption with encoding (Base64)?
|
|
15
|
+
Answer no when the data stays encrypted, or only a non-sensitive value is stored in plain form.
|
|
16
|
+
weak_password_hash: >-
|
|
17
|
+
Does the code store or compare passwords, or other low-entropy secrets such as short codes, with a fast digest (MD5,
|
|
18
|
+
SHA1, SHA256 without a password hashing scheme) or without a salt? Answer no when it uses has_secure_password,
|
|
19
|
+
bcrypt, scrypt, or Argon2, or when the hashed value is a long random token where a fast digest is fine.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Concrete patterns that are each enough to make mass assignment real on their own.
|
|
2
|
+
# Each question asks for the pattern AND a missing defense, so that code which uses the same construct safely is a no.
|
|
3
|
+
permit_all: >-
|
|
4
|
+
Does the code create or update a model with request parameters that are not limited to a fixed list of
|
|
5
|
+
attributes, through params.permit!, to_unsafe_h, to_unsafe_hash, permit(*Model.column_names), or a permit list
|
|
6
|
+
chosen by the client, in an action regular users can call? Answer no when the parameters are limited with permit
|
|
7
|
+
or expect to a fixed list, or when a hash permit such as permit(preferences: {}) only fills a single JSON or
|
|
8
|
+
serialized column.
|
|
9
|
+
sensitive_attribute: >-
|
|
10
|
+
Does an action that regular users can call (sign-up, profile or account update, a member API) permit attributes
|
|
11
|
+
that users should not control, such as role, admin, owner_id, user_id, account_id, balance, price, plan, verified,
|
|
12
|
+
or confirmed_at? Answer no when such attributes are only permitted in an admin-only action that checks the user is
|
|
13
|
+
an administrator, or are set on the server instead of from params.
|
|
14
|
+
strong_params_bypass: >-
|
|
15
|
+
Does the code pass request data to a model write (new, create, update, assign_attributes, insert_all, upsert)
|
|
16
|
+
without going through strong parameters, for example JSON.parse(request.body.read), request.request_parameters,
|
|
17
|
+
request.POST, or the arguments of a GraphQL mutation (GraphQL input objects are not protected by strong
|
|
18
|
+
parameters, so every argument the input type declares can be written)? Answer no when only explicitly listed,
|
|
19
|
+
harmless attributes are taken from that data, or the GraphQL input type declares only harmless arguments.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Concrete patterns that are each enough to make an open redirect real on their own.
|
|
2
|
+
# Each question asks for the pattern AND a missing defense, so that code which uses the same construct safely is a no.
|
|
3
|
+
other_host_allowed: >-
|
|
4
|
+
Does the code redirect to a URL that may come from users (params, a value saved in the session from params, or
|
|
5
|
+
the Referer header) in a way that allows other hosts: redirect_to or redirect_back with allow_other_host: true,
|
|
6
|
+
raise_on_open_redirects turned off, or a redirect outside Rails' redirect_to such as a Sinatra or Rack redirect
|
|
7
|
+
or a Location header set by hand? Answer no when the destination is checked first with url_from, or its parsed
|
|
8
|
+
host is compared exactly against a fixed list, or Rails' default protection (Rails 7+) is left on.
|
|
9
|
+
weak_url_check: >-
|
|
10
|
+
Does the code decide that a redirect destination is safe with a string check that can be fooled: start_with?,
|
|
11
|
+
include?, or end_with? on the URL or host, or only checking that the value starts with "/" (which lets
|
|
12
|
+
//evil.example and /\evil.example through)? Answer no when the URL is parsed and its host compared exactly, or
|
|
13
|
+
when a path check also rejects values starting with "//" or "/\".
|
|
14
|
+
client_side_redirect: >-
|
|
15
|
+
Does front-end JavaScript send the browser to a URL taken from the query string, the hash, or other
|
|
16
|
+
user-controlled input (window.location, location.href, location.assign, location.replace) without making sure it
|
|
17
|
+
stays on the same origin? Answer no when it only accepts URLs whose origin equals location.origin.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Concrete patterns that are each enough to make SQL injection real on their own.
|
|
2
|
+
# Each question asks for the pattern AND a missing defense, so that code which uses the same construct safely is a no.
|
|
3
|
+
string_built_query: >-
|
|
4
|
+
Does the code interpolate or concatenate data that may come from users into a SQL fragment passed to an
|
|
5
|
+
ActiveRecord method that accepts raw SQL (where, having, joins, from, lock, find_by_sql)? Answer no when the value
|
|
6
|
+
is passed through a ? or named placeholder, a hash condition, sanitize_sql_array, connection.quote, or is cast to
|
|
7
|
+
an integer first.
|
|
8
|
+
raw_connection: >-
|
|
9
|
+
Does the code run SQL through the database connection directly (connection.execute, select_all, select_value,
|
|
10
|
+
select_rows, exec_query) with data that may come from users interpolated into the SQL string? Answer no when the
|
|
11
|
+
values are bound as parameters, quoted with connection.quote, or cast to integers.
|
|
12
|
+
raw_sql_method: >-
|
|
13
|
+
Does data that may come from users reach a method that takes raw SQL without checking it: Arel.sql(...),
|
|
14
|
+
select("..."), group("..."), or order / reorder / pluck with Arel.sql? Answer no when the value is mapped through
|
|
15
|
+
a fixed allowlist of column names or directions, or passed as a symbol or hash such as order(created_at: :desc).
|
|
16
|
+
Note that plain order(params[:sort]) without Arel.sql is rejected by Rails 6+ for anything but a column name.
|
|
17
|
+
like_and_in_clauses: >-
|
|
18
|
+
Does the code build a LIKE pattern or an IN (...) list by putting data that may come from users directly into
|
|
19
|
+
the SQL string, for example where("name LIKE '%#{q}%'") or where("id IN (#{ids.join(',')})")? Answer no when it
|
|
20
|
+
uses where("name LIKE ?", "%#{sanitize_sql_like(q)}%"), a placeholder, or a hash condition such as where(id: ids).
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Concrete patterns that are each enough to make XSS real on their own.
|
|
2
|
+
# The classifier answers each one separately, alongside the overall XSS question.
|
|
3
|
+
# Each question asks for the pattern AND a missing defense, so that code which uses the same construct safely is a no.
|
|
4
|
+
raw_output: >-
|
|
5
|
+
Does the code send data that may come from users to the page as HTML without escaping it? Answer yes when such
|
|
6
|
+
data reaches raw, html_safe, <%== %>, or safe_concat, or is interpolated into a string that is then marked
|
|
7
|
+
html_safe, without being escaped first. Answer no when every user value is escaped (h, ERB::Util.html_escape,
|
|
8
|
+
sanitize) or the HTML is built with tag helpers (content_tag, tag.*, link_to, safe_join) before being marked safe.
|
|
9
|
+
js_context: >-
|
|
10
|
+
Does the code put data that may come from users into JavaScript (an inline <script> block, an event handler
|
|
11
|
+
attribute such as onclick, or a JavaScript string) without JavaScript-safe escaping? This includes raw output,
|
|
12
|
+
and JSON produced by JSON.generate or a JSON library other than Rails' to_json, because it does not escape
|
|
13
|
+
</script>. Answer no when the data goes through json_escape, j / escape_javascript, or Rails' to_json, which
|
|
14
|
+
escape the characters that could end the script.
|
|
15
|
+
url_attribute: >-
|
|
16
|
+
Does the code put a URL that may come from users into href, src, action, or formaction without making sure it is
|
|
17
|
+
not a javascript: URL? Answer no when the scheme is checked to be http or https, the URL comes from a route helper
|
|
18
|
+
or a fixed path, or the content goes through sanitize, which removes javascript: URLs.
|
|
19
|
+
html_response: >-
|
|
20
|
+
Does the code build HTML by interpolating data that may come from users into a string and send it to the browser
|
|
21
|
+
outside the escaping template engine (render html:, render inline:, or a Rack or Sinatra response body), without
|
|
22
|
+
escaping that data? Answer no when every interpolated value is escaped (h, ERB::Util.html_escape,
|
|
23
|
+
Rack::Utils.escape_html).
|
|
24
|
+
sanitizer_config: >-
|
|
25
|
+
Does the code change how content that may come from users is sanitized or rendered so that dangerous HTML can now
|
|
26
|
+
get through? For example allowing script, iframe, or object tags or on* attributes in sanitize, or enabling raw
|
|
27
|
+
HTML in a Markdown renderer (Commonmarker unsafe: true, Redcarpet without filter_html). Answer no when the change
|
|
28
|
+
keeps raw HTML disabled and keeps the default sanitize allowlist.
|
|
29
|
+
dom_sink: >-
|
|
30
|
+
Does front-end JavaScript insert data that may come from users or from the server into the page as HTML, through
|
|
31
|
+
innerHTML, outerHTML, insertAdjacentHTML, document.write, or jQuery .html()? Answer no when it only uses
|
|
32
|
+
textContent, createTextNode, or creates elements and sets their text.
|
|
33
|
+
attribute_injection: >-
|
|
34
|
+
Does the code build an HTML attribute value by interpolating data that may come from users into a string, without
|
|
35
|
+
escaping the quotes, so that the value could end the attribute and add a new one such as onmouseover? Answer no
|
|
36
|
+
when the value is escaped with h or ERB::Util.html_escape (which escape quotes) or the attribute is set through a
|
|
37
|
+
tag helper.
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'ai_analysis_engine/ruby_llm_context'
|
|
4
|
+
require_relative 'ai_analysis_engine/llm_client'
|
|
5
|
+
require_relative 'ai_analysis_engine/judge_client'
|
|
6
|
+
require_relative 'ai_analysis_engine/cli_clients/claude_code_client'
|
|
7
|
+
require_relative 'ai_analysis_engine/cli_clients/codex_client'
|
|
8
|
+
require_relative 'ai_analysis_engine/fallback_client'
|
|
9
|
+
|
|
10
|
+
module Omamori
|
|
11
|
+
# Decides which classifier and reporter to use, from .omamorirc and command-line options.
|
|
12
|
+
#
|
|
13
|
+
# classifier: # omit to let the reporter check everything itself
|
|
14
|
+
# provider: typesafe # any RubyLLM provider that can judge (typesafe, openai, ollama)
|
|
15
|
+
# model: jev-1.13.0
|
|
16
|
+
# sub_checks: true # also ask each risk's concrete patterns (sub_checks/*.yml)
|
|
17
|
+
# reporter:
|
|
18
|
+
# provider: anthropic # any RubyLLM provider, or claude_code / codex (subscription CLIs)
|
|
19
|
+
# model: claude-sonnet-5-5
|
|
20
|
+
# fallback: # optional; asked only when the reporter refuses or fails
|
|
21
|
+
# provider: claude_code
|
|
22
|
+
# model: sonnet
|
|
23
|
+
#
|
|
24
|
+
# There is no default reporter: `omamori init --reporter ...` writes one. The older keys (engine, llm,
|
|
25
|
+
# model, provider, api_key, decision, claude, codex) are still read; an old config with only `api_key`
|
|
26
|
+
# keeps using Gemini, as it did before.
|
|
27
|
+
class AISettings
|
|
28
|
+
class Error < StandardError; end
|
|
29
|
+
|
|
30
|
+
CLI_REPORTERS = %w[claude_code codex].freeze
|
|
31
|
+
# The reporter of omamori 0.1, used only for old configs that set the top-level api_key (a Gemini key)
|
|
32
|
+
LEGACY_REPORTER = { 'provider' => 'gemini', 'model' => AIAnalysisEngine::LLMClient::DEFAULT_MODEL }.freeze
|
|
33
|
+
# Classifier providers whose model can be omitted
|
|
34
|
+
DEFAULT_CLASSIFIER_MODELS = { 'typesafe' => 'jev-1.13.0', 'openai' => 'gpt-6-luna' }.freeze
|
|
35
|
+
# Questions per request accepted by each classifier provider (no entry: no limit)
|
|
36
|
+
MAX_QUESTIONS = { 'openai' => 200 }.freeze
|
|
37
|
+
# RubyLLM protocols that answer judgment questions
|
|
38
|
+
JUDGE_PROTOCOLS = %i[system_one decisions].freeze
|
|
39
|
+
DEFAULT_THRESHOLD = 0.5
|
|
40
|
+
DEFAULT_CLASSIFIER_CONCURRENCY = 8
|
|
41
|
+
DEFAULT_API_REPORTER_CONCURRENCY = 8
|
|
42
|
+
# Subscription CLIs have tighter rate limits than APIs
|
|
43
|
+
DEFAULT_CLI_REPORTER_CONCURRENCY = 3
|
|
44
|
+
|
|
45
|
+
LEGACY_ENGINES = { 'jev' => 'typesafe', 'openai' => 'openai' }.freeze
|
|
46
|
+
LEGACY_NO_CLASSIFIER = %w[llm gemini].freeze
|
|
47
|
+
LEGACY_LLMS = { 'claude' => 'claude_code', 'codex' => 'codex' }.freeze
|
|
48
|
+
|
|
49
|
+
# The classifier settings, or nil when the reporter checks everything itself
|
|
50
|
+
attr_reader :classifier
|
|
51
|
+
# The reporter settings, or nil when none is configured (`omamori scan` stops with #reporter_missing_message)
|
|
52
|
+
attr_reader :reporter
|
|
53
|
+
|
|
54
|
+
# overrides: classifier:, classifier_model:, reporter:, reporter_model:, reporter_fallback:,
|
|
55
|
+
# reporter_fallback_model: (from the command line)
|
|
56
|
+
def initialize(config, overrides = {})
|
|
57
|
+
@config = config
|
|
58
|
+
@overrides = overrides.compact.transform_values(&:to_s)
|
|
59
|
+
@classifier = resolve_classifier
|
|
60
|
+
@reporter = resolve_reporter
|
|
61
|
+
@reporter_fallback = resolve_reporter_fallback
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def classifier?
|
|
65
|
+
!@classifier.nil?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def reporter?
|
|
69
|
+
!@reporter.nil?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def cli_reporter?
|
|
73
|
+
reporter? && CLI_REPORTERS.include?(@reporter['provider'])
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def threshold
|
|
77
|
+
@classifier.fetch('threshold', DEFAULT_THRESHOLD)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Whether each risk is also asked as concrete sub-checks (on unless `classifier.sub_checks: false`)
|
|
81
|
+
def sub_checks?
|
|
82
|
+
@classifier.fetch('sub_checks', true)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def classifier_concurrency
|
|
86
|
+
@classifier.fetch('concurrency', DEFAULT_CLASSIFIER_CONCURRENCY)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def reporter_concurrency
|
|
90
|
+
@reporter.fetch('concurrency', cli_reporter? ? DEFAULT_CLI_REPORTER_CONCURRENCY : DEFAULT_API_REPORTER_CONCURRENCY)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def reporter_model
|
|
94
|
+
@reporter&.fetch('model', nil)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.reporter_missing_message(command: 'scan')
|
|
98
|
+
message = <<~MESSAGE
|
|
99
|
+
No reporter is configured. Choose the LLM that writes reports, for example:
|
|
100
|
+
|
|
101
|
+
omamori init --reporter claude_code # Claude Code subscription (claude -p)
|
|
102
|
+
omamori init --reporter codex # Codex subscription (codex exec)
|
|
103
|
+
omamori init --reporter anthropic --reporter-model claude-sonnet-5-5 # Any RubyLLM provider and model
|
|
104
|
+
MESSAGE
|
|
105
|
+
return message unless command == 'scan'
|
|
106
|
+
|
|
107
|
+
"#{message}\nOr pass --reporter (and --reporter-model) to `omamori scan`.\n"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The environment variable that holds the API key of a RubyLLM provider, or nil when it has none
|
|
111
|
+
# (claude_code / codex use the CLI's login; some providers only need a server address)
|
|
112
|
+
def self.api_key_env(provider)
|
|
113
|
+
return nil if provider.nil? || CLI_REPORTERS.include?(provider)
|
|
114
|
+
|
|
115
|
+
name = "#{provider}_api_key"
|
|
116
|
+
RubyLLM.config.respond_to?("#{name}=") ? name.upcase : nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def build_classifier_client
|
|
120
|
+
return nil unless classifier?
|
|
121
|
+
|
|
122
|
+
AIAnalysisEngine::JudgeClient.new(
|
|
123
|
+
ruby_llm_context,
|
|
124
|
+
provider: @classifier['provider'].to_sym, model: @classifier['model'],
|
|
125
|
+
max_questions: @classifier.fetch('max_questions', MAX_QUESTIONS[@classifier['provider']])
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def build_reporter_client
|
|
130
|
+
return nil unless reporter?
|
|
131
|
+
|
|
132
|
+
client = build_llm_client(@reporter)
|
|
133
|
+
return client unless @reporter_fallback
|
|
134
|
+
|
|
135
|
+
AIAnalysisEngine::FallbackClient.new(client, build_llm_client(@reporter_fallback),
|
|
136
|
+
fallback_model: @reporter_fallback['model'])
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
attr_reader :reporter_fallback
|
|
140
|
+
|
|
141
|
+
# A client for one reporter's settings (the reporter or its fallback)
|
|
142
|
+
def build_llm_client(settings)
|
|
143
|
+
case settings['provider']
|
|
144
|
+
when 'claude_code' then AIAnalysisEngine::CLIClients::ClaudeCodeClient.new(command: settings['command'])
|
|
145
|
+
when 'codex' then AIAnalysisEngine::CLIClients::CodexClient.new(command: settings['command'])
|
|
146
|
+
else AIAnalysisEngine::LLMClient.new(ruby_llm_context, provider: settings['provider']&.to_sym)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# One line for the console, e.g. "classifier: typesafe/jev-1.13.0, reporter: gemini/gemini-3.8-flash"
|
|
151
|
+
def describe
|
|
152
|
+
reporter = reporter? ? [@reporter['provider'], @reporter['model']].compact.join('/') : 'none'
|
|
153
|
+
reporter += " (fallback: #{[@reporter_fallback['provider'], @reporter_fallback['model']].compact.join('/')})" if @reporter_fallback
|
|
154
|
+
return "reporter: #{reporter}" unless classifier?
|
|
155
|
+
|
|
156
|
+
"classifier: #{@classifier['provider']}/#{@classifier['model']}, reporter: #{reporter}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def get(key)
|
|
162
|
+
@config.get(key, nil)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def resolve_classifier
|
|
166
|
+
settings = apply_override(configured_classifier, @overrides[:classifier], @overrides[:classifier_model])
|
|
167
|
+
return nil if settings.nil? || settings['provider'] == 'none'
|
|
168
|
+
|
|
169
|
+
provider = settings.fetch('provider') { raise Error, 'classifier.provider is required' }
|
|
170
|
+
validate_classifier_provider!(provider)
|
|
171
|
+
settings['model'] ||= DEFAULT_CLASSIFIER_MODELS.fetch(provider) do
|
|
172
|
+
raise Error, "classifier.model is required for provider #{provider}"
|
|
173
|
+
end
|
|
174
|
+
settings
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def resolve_reporter
|
|
178
|
+
settings = apply_override(configured_reporter, @overrides[:reporter], @overrides[:reporter_model])
|
|
179
|
+
settings = LEGACY_REPORTER.merge(settings || {}) if legacy_gemini?(settings)
|
|
180
|
+
return nil if settings.nil? || (settings['provider'].nil? && settings['model'].nil?)
|
|
181
|
+
|
|
182
|
+
validate_reporter(settings.except('fallback'))
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def resolve_reporter_fallback
|
|
186
|
+
# Read from the configured reporter, so a --reporter override keeps the configured fallback
|
|
187
|
+
configured = configured_reporter&.fetch('fallback', nil)&.then { |fallback| stringify(fallback) }
|
|
188
|
+
settings = apply_override(configured, @overrides[:reporter_fallback], @overrides[:reporter_fallback_model])
|
|
189
|
+
return nil if settings.nil? || settings['provider'] == 'none'
|
|
190
|
+
|
|
191
|
+
validate_reporter(settings, role: 'reporter.fallback')
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def validate_reporter(settings, role: 'reporter')
|
|
195
|
+
provider = settings['provider']
|
|
196
|
+
return settings if provider.nil? || CLI_REPORTERS.include?(provider)
|
|
197
|
+
|
|
198
|
+
raise Error, "Unknown #{role} provider: #{provider}" unless RubyLLM::Provider.resolve(provider)
|
|
199
|
+
raise Error, "#{role}.model is required for provider #{provider}" unless settings['model']
|
|
200
|
+
|
|
201
|
+
settings
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# A provider given on the command line replaces the configured one; the configured model only
|
|
205
|
+
# carries over when the provider stays the same.
|
|
206
|
+
def apply_override(settings, provider, model)
|
|
207
|
+
if provider
|
|
208
|
+
settings = settings&.fetch('provider', nil) == provider ? settings : {}
|
|
209
|
+
settings = settings.merge('provider' => provider)
|
|
210
|
+
end
|
|
211
|
+
settings = (settings || {}).merge('model' => model) if model
|
|
212
|
+
settings
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# omamori 0.1 configs had only the Gemini `api_key` (and maybe `model`), and used Gemini by default
|
|
216
|
+
def legacy_gemini?(settings)
|
|
217
|
+
get('api_key') && (settings.nil? || (settings['provider'].nil? && settings['model'].nil?))
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def configured_classifier
|
|
221
|
+
return stringify(get('classifier')) if get('classifier')
|
|
222
|
+
|
|
223
|
+
engine = get('engine')&.to_s
|
|
224
|
+
return nil if engine.nil? || LEGACY_NO_CLASSIFIER.include?(engine)
|
|
225
|
+
|
|
226
|
+
provider = LEGACY_ENGINES.fetch(engine) { raise Error, "Unknown engine: #{engine}" }
|
|
227
|
+
decision = stringify(get('decision') || {})
|
|
228
|
+
engine_settings = stringify(decision[engine] || {})
|
|
229
|
+
{ 'provider' => provider, 'threshold' => decision['threshold'], 'concurrency' => decision['concurrency'] }
|
|
230
|
+
.merge(engine_settings.slice('model', 'base_url', 'api_key')).compact
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def configured_reporter
|
|
234
|
+
return stringify(get('reporter')) if get('reporter')
|
|
235
|
+
|
|
236
|
+
llm = get('llm')&.to_s
|
|
237
|
+
report_concurrency = stringify(get('decision') || {})['report_concurrency']
|
|
238
|
+
settings = if LEGACY_LLMS.key?(llm)
|
|
239
|
+
{ 'provider' => LEGACY_LLMS[llm] }.merge(stringify(get(llm) || {}).slice('model', 'command'))
|
|
240
|
+
else
|
|
241
|
+
{ 'provider' => get('provider')&.to_s, 'model' => get('model') }
|
|
242
|
+
end
|
|
243
|
+
settings.merge('concurrency' => report_concurrency).compact.then { |s| s.empty? ? nil : s }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def validate_classifier_provider!(provider)
|
|
247
|
+
provider_class = RubyLLM::Provider.resolve(provider)
|
|
248
|
+
raise Error, "Unknown classifier provider: #{provider}" unless provider_class
|
|
249
|
+
return if provider_class.protocols.keys.intersect?(JUDGE_PROTOCOLS)
|
|
250
|
+
|
|
251
|
+
raise Error, "Provider #{provider} cannot be used as a classifier"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Each role's api_key / base_url go to RubyLLM's <provider>_api_key / <provider>_api_base.
|
|
255
|
+
# The old top-level api_key is a Gemini key.
|
|
256
|
+
def ruby_llm_context
|
|
257
|
+
@ruby_llm_context ||= begin
|
|
258
|
+
overrides = { gemini_api_key: get('api_key') }
|
|
259
|
+
[@classifier, @reporter, @reporter_fallback].compact.each do |settings|
|
|
260
|
+
next unless settings['provider'] && !CLI_REPORTERS.include?(settings['provider'])
|
|
261
|
+
|
|
262
|
+
overrides[:"#{settings['provider']}_api_key"] = settings['api_key'] if settings['api_key']
|
|
263
|
+
overrides[:"#{settings['provider']}_api_base"] = settings['base_url'] if settings['base_url']
|
|
264
|
+
end
|
|
265
|
+
AIAnalysisEngine::RubyLLMContext.build(overrides)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def stringify(hash)
|
|
270
|
+
hash.to_h.transform_keys(&:to_s)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
data/lib/omamori/config.rb
CHANGED
|
@@ -4,11 +4,15 @@ require 'yaml'
|
|
|
4
4
|
|
|
5
5
|
module Omamori
|
|
6
6
|
class Config
|
|
7
|
-
DEFAULT_CONFIG_PATH =
|
|
7
|
+
DEFAULT_CONFIG_PATH = '.omamorirc'
|
|
8
|
+
DEFAULT_IGNORE_PATH = '.omamoriignore'
|
|
9
|
+
|
|
10
|
+
attr_reader :ignore_patterns
|
|
8
11
|
|
|
9
12
|
def initialize(config_path = DEFAULT_CONFIG_PATH)
|
|
10
13
|
@config_path = config_path
|
|
11
14
|
@config = load_config
|
|
15
|
+
@ignore_patterns = load_ignore_patterns # Load ignore patterns
|
|
12
16
|
validate_config # Add validation after loading
|
|
13
17
|
end
|
|
14
18
|
|
|
@@ -31,100 +35,116 @@ module Omamori
|
|
|
31
35
|
end
|
|
32
36
|
|
|
33
37
|
def validate_api_key
|
|
34
|
-
api_key = @config[
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
api_key = @config['api_key']
|
|
39
|
+
return unless api_key && !api_key.is_a?(String)
|
|
40
|
+
|
|
41
|
+
puts "Warning: Config 'api_key' should be a string."
|
|
38
42
|
end
|
|
39
43
|
|
|
40
44
|
def validate_model
|
|
41
|
-
model = @config[
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
model = @config['model']
|
|
46
|
+
return unless model && !model.is_a?(String)
|
|
47
|
+
|
|
48
|
+
puts "Warning: Config 'model' should be a string."
|
|
45
49
|
end
|
|
46
50
|
|
|
47
51
|
def validate_checks
|
|
48
|
-
checks = @config[
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
checks = @config['checks']
|
|
53
|
+
return unless checks && !checks.is_a?(Array)
|
|
54
|
+
|
|
55
|
+
puts "Warning: Config 'checks' should be an array."
|
|
52
56
|
end
|
|
53
57
|
|
|
54
58
|
def validate_prompt_templates
|
|
55
|
-
prompt_templates = @config[
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
prompt_templates = @config['prompt_templates']
|
|
60
|
+
return unless prompt_templates && !prompt_templates.is_a?(Hash)
|
|
61
|
+
|
|
62
|
+
puts "Warning: Config 'prompt_templates' should be a hash."
|
|
59
63
|
end
|
|
60
64
|
|
|
61
65
|
def validate_report_settings
|
|
62
|
-
report = @config[
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if report.key?("html_template") && !report["html_template"].is_a?(String)
|
|
72
|
-
puts "Warning: Config 'report.html_template' should be a string."
|
|
73
|
-
end
|
|
66
|
+
report = @config['report']
|
|
67
|
+
return unless report
|
|
68
|
+
|
|
69
|
+
unless report.is_a?(Hash)
|
|
70
|
+
puts "Warning: Config 'report' should be a hash."
|
|
71
|
+
return
|
|
72
|
+
end
|
|
73
|
+
if report.key?('output_path') && !report['output_path'].is_a?(String)
|
|
74
|
+
puts "Warning: Config 'report.output_path' should be a string."
|
|
74
75
|
end
|
|
76
|
+
return unless report.key?('html_template') && !report['html_template'].is_a?(String)
|
|
77
|
+
|
|
78
|
+
puts "Warning: Config 'report.html_template' should be a string."
|
|
75
79
|
end
|
|
76
80
|
|
|
77
81
|
def validate_static_analyser_settings
|
|
78
|
-
static_analysers = @config[
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
puts "Warning: Config 'static_analysers.brakeman.options' should be a string."
|
|
91
|
-
end
|
|
82
|
+
static_analysers = @config['static_analysers']
|
|
83
|
+
return unless static_analysers
|
|
84
|
+
|
|
85
|
+
unless static_analysers.is_a?(Hash)
|
|
86
|
+
puts "Warning: Config 'static_analysers' should be a hash."
|
|
87
|
+
return
|
|
88
|
+
end
|
|
89
|
+
if static_analysers.key?('brakeman')
|
|
90
|
+
brakeman_config = static_analysers['brakeman']
|
|
91
|
+
if brakeman_config.is_a?(Hash)
|
|
92
|
+
if brakeman_config.key?('options') && !brakeman_config['options'].is_a?(String)
|
|
93
|
+
puts "Warning: Config 'static_analysers.brakeman.options' should be a string."
|
|
92
94
|
end
|
|
95
|
+
else
|
|
96
|
+
puts "Warning: Config 'static_analysers.brakeman' should be a hash."
|
|
93
97
|
end
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
end
|
|
102
|
-
end
|
|
98
|
+
end
|
|
99
|
+
return unless static_analysers.key?('bundler_audit')
|
|
100
|
+
|
|
101
|
+
bundler_audit_config = static_analysers['bundler_audit']
|
|
102
|
+
if bundler_audit_config.is_a?(Hash)
|
|
103
|
+
if bundler_audit_config.key?('options') && !bundler_audit_config['options'].is_a?(String)
|
|
104
|
+
puts "Warning: Config 'static_analysers.bundler_audit.options' should be a string."
|
|
103
105
|
end
|
|
106
|
+
else
|
|
107
|
+
puts "Warning: Config 'static_analysers.bundler_audit' should be a hash."
|
|
104
108
|
end
|
|
105
109
|
end
|
|
106
110
|
|
|
107
|
-
|
|
108
111
|
def validate_ci_setup_settings
|
|
109
|
-
ci_setup = @config[
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if ci_setup.key?("github_actions_path") && !ci_setup["github_actions_path"].is_a?(String)
|
|
116
|
-
puts "Warning: Config 'ci_setup.github_actions_path' should be a string."
|
|
117
|
-
end
|
|
118
|
-
if ci_setup.key?("gitlab_ci_path") && !ci_setup["gitlab_ci_path"].is_a?(String)
|
|
119
|
-
puts "Warning: Config 'ci_setup.gitlab_ci_path' should be a string."
|
|
120
|
-
end
|
|
112
|
+
ci_setup = @config['ci_setup']
|
|
113
|
+
return unless ci_setup
|
|
114
|
+
|
|
115
|
+
unless ci_setup.is_a?(Hash)
|
|
116
|
+
puts "Warning: Config 'ci_setup' should be a hash."
|
|
117
|
+
return
|
|
121
118
|
end
|
|
119
|
+
if ci_setup.key?('github_actions_path') && !ci_setup['github_actions_path'].is_a?(String)
|
|
120
|
+
puts "Warning: Config 'ci_setup.github_actions_path' should be a string."
|
|
121
|
+
end
|
|
122
|
+
return unless ci_setup.key?('gitlab_ci_path') && !ci_setup['gitlab_ci_path'].is_a?(String)
|
|
123
|
+
|
|
124
|
+
puts "Warning: Config 'ci_setup.gitlab_ci_path' should be a string."
|
|
122
125
|
end
|
|
123
126
|
|
|
124
127
|
def validate_language
|
|
125
|
-
language = @config[
|
|
126
|
-
|
|
127
|
-
|
|
128
|
+
language = @config['language']
|
|
129
|
+
return unless language && !language.is_a?(String)
|
|
130
|
+
|
|
131
|
+
puts "Warning: Config 'language' should be a string."
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Load .omamoriignore file and return an array of ignore patterns
|
|
135
|
+
def load_ignore_patterns
|
|
136
|
+
ignore_path = DEFAULT_IGNORE_PATH
|
|
137
|
+
if File.exist?(ignore_path)
|
|
138
|
+
begin
|
|
139
|
+
File.readlines(ignore_path, chomp: true).reject do |line|
|
|
140
|
+
line.strip.empty? || line.strip.start_with?('#')
|
|
141
|
+
end
|
|
142
|
+
rescue StandardError => e
|
|
143
|
+
puts "Warning: Error reading .omamoriignore file #{ignore_path}: #{e.message}"
|
|
144
|
+
[] # Return empty array if reading fails
|
|
145
|
+
end
|
|
146
|
+
else
|
|
147
|
+
[] # Return empty array if file does not exist
|
|
128
148
|
end
|
|
129
149
|
end
|
|
130
150
|
|
|
@@ -141,4 +161,4 @@ module Omamori
|
|
|
141
161
|
end
|
|
142
162
|
end
|
|
143
163
|
end
|
|
144
|
-
end
|
|
164
|
+
end
|