scryer 1.2.1 → 1.3.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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +129 -10
  3. data/README.md +9 -4
  4. data/docs/architecture.md +45 -36
  5. data/docs/rails-integration.md +182 -1
  6. data/docs/rules.md +8 -1
  7. data/docs/usage.md +5 -2
  8. data/lib/generators/scryer/install_generator.rb +1 -0
  9. data/lib/generators/scryer/templates/scryer_initializer.rb +22 -0
  10. data/lib/scryer/ai_client.rb +1 -0
  11. data/lib/scryer/ai_fix_suggester.rb +1 -0
  12. data/lib/scryer/apm.rb +458 -0
  13. data/lib/scryer/ast.rb +53 -0
  14. data/lib/scryer/authorization_watcher.rb +1 -0
  15. data/lib/scryer/baseline.rb +1 -0
  16. data/lib/scryer/cache_extractor.rb +1 -0
  17. data/lib/scryer/cli.rb +19 -11
  18. data/lib/scryer/colorizer.rb +1 -0
  19. data/lib/scryer/dependency_audit.rb +1 -0
  20. data/lib/scryer/dependency_fixer.rb +1 -0
  21. data/lib/scryer/duplicate_detector.rb +1 -0
  22. data/lib/scryer/finding.rb +1 -0
  23. data/lib/scryer/fix_runner.rb +1 -0
  24. data/lib/scryer/fix_verifier.rb +1 -0
  25. data/lib/scryer/mechanical_fixer.rb +1 -0
  26. data/lib/scryer/method_extractor.rb +1 -0
  27. data/lib/scryer/minitest.rb +1 -0
  28. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +1 -0
  29. data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
  30. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +2 -0
  31. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +9 -1
  32. data/lib/scryer/query_extractor.rb +1 -0
  33. data/lib/scryer/query_watcher.rb +1 -0
  34. data/lib/scryer/railtie.rb +64 -4
  35. data/lib/scryer/report_renderer.rb +161 -99
  36. data/lib/scryer/rspec.rb +1 -0
  37. data/lib/scryer/rule.rb +15 -2
  38. data/lib/scryer/rule_set.rb +1 -0
  39. data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +1 -0
  40. data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +1 -0
  41. data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +1 -0
  42. data/lib/scryer/rules/authentication_bypass_rule.rb +1 -0
  43. data/lib/scryer/rules/command_injection_rule.rb +1 -0
  44. data/lib/scryer/rules/consider_all_requests_local_rule.rb +1 -0
  45. data/lib/scryer/rules/cors_misconfiguration_rule.rb +1 -0
  46. data/lib/scryer/rules/csrf_protection_rule.rb +1 -0
  47. data/lib/scryer/rules/dangerous_eval_rule.rb +117 -0
  48. data/lib/scryer/rules/force_ssl_rule.rb +1 -0
  49. data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +1 -0
  50. data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +1 -0
  51. data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +1 -0
  52. data/lib/scryer/rules/hardcoded_secret_rule.rb +1 -0
  53. data/lib/scryer/rules/host_authorization_disabled_rule.rb +1 -0
  54. data/lib/scryer/rules/idor_rule.rb +6 -14
  55. data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +1 -0
  56. data/lib/scryer/rules/job_raw_params_rule.rb +1 -0
  57. data/lib/scryer/rules/jwt_insecure_rule.rb +1 -0
  58. data/lib/scryer/rules/mass_assignment_rule.rb +10 -16
  59. data/lib/scryer/rules/missing_authorization_rule.rb +1 -0
  60. data/lib/scryer/rules/missing_policy_scope_rule.rb +2 -9
  61. data/lib/scryer/rules/open_redirect_rule.rb +1 -0
  62. data/lib/scryer/rules/path_traversal_rule.rb +1 -0
  63. data/lib/scryer/rules/security_headers_rule.rb +1 -0
  64. data/lib/scryer/rules/sql_injection_rule.rb +1 -0
  65. data/lib/scryer/rules/ssrf_rule.rb +1 -0
  66. data/lib/scryer/rules/unsafe_deserialization_rule.rb +1 -0
  67. data/lib/scryer/rules/verbose_production_log_level_rule.rb +1 -0
  68. data/lib/scryer/rules/weak_crypto_rule.rb +1 -0
  69. data/lib/scryer/rules/weak_session_cookie_rule.rb +1 -0
  70. data/lib/scryer/rules/xss_unsafe_html_rule.rb +1 -0
  71. data/lib/scryer/scanner.rb +126 -7
  72. data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
  73. data/lib/scryer/version.rb +2 -1
  74. data/lib/scryer.rb +49 -0
  75. data/lib/tasks/scryer.rake +8 -2
  76. metadata +3 -1
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  # Optional, opt-in enrichment of a finding's `suggested_fix` using an
3
4
  # external LLM. Every rule already ships a generic, human-reviewable
data/lib/scryer/apm.rb ADDED
@@ -0,0 +1,458 @@
1
+ # frozen_string_literal: true
2
+ require "securerandom"
3
+
4
+ module Scryer
5
+ # Runtime method-level tracing — answers "which method actually ate the
6
+ # time this New Relic (or any other APM) lumps into Others/Application
7
+ # Code". Built the same way Scryer::QueryWatcher already is: a
8
+ # `Module#prepend` generated per instrumented class, state kept in
9
+ # `Thread.current` (never a global mutable collection — concurrent
10
+ # requests on a multi-threaded server must never see each other's spans,
11
+ # the same lesson QueryWatcher's own docs call out), and zero new gem
12
+ # dependencies. No custom HTTP exporter of its own: when a supported APM
13
+ # agent/SDK is loaded (currently: the New Relic Ruby agent, or the
14
+ # opentelemetry-ruby SDK), spans are created through *that agent's own*
15
+ # public, documented API — `NewRelic::Agent::Tracer.start_segment` for
16
+ # New Relic, `OpenTelemetry.tracer_provider.tracer(...).start_span` for
17
+ # OpenTelemetry — so they nest inside that agent's own trace/transaction
18
+ # and ride its own already-async, already-batched, already-retried export
19
+ # pipeline. Either SDK is soft-detected (`defined?(...)`), never a hard
20
+ # gem dependency of Scryer itself — a host app that wants the
21
+ # `:opentelemetry` provider adds `opentelemetry-sdk` (and an exporter, e.g.
22
+ # `opentelemetry-exporter-otlp`) to its own Gemfile, same as it already
23
+ # does for `newrelic_rpm` today. This module doesn't need to (and
24
+ # doesn't) implement its own export queue, batching, or backoff, because
25
+ # it never talks to a network endpoint itself.
26
+ #
27
+ # Three things this deliberately does NOT do, all by design, not omission:
28
+ # - Capture method argument values, local variables, or return values.
29
+ # Only class name, method name, file:line (captured once at wrap
30
+ # time, not per call), timing, and exception *class* are recorded.
31
+ # See `capture_exception_messages` below for the one opt-in exception
32
+ # to "no payload data ever".
33
+ # - Instrument methods *inherited* from a superclass/module. Only
34
+ # methods defined directly on the named class/module
35
+ # (`instance_methods(false)`) are wrapped — instrumenting inherited
36
+ # framework methods (ActiveRecord::Base, etc.) via a plain class-name
37
+ # entry in `include` would be exactly the "sensitive framework
38
+ # internals" this module is required to avoid.
39
+ # - Implement :discovery or :deep_trace modes. Only :off and :selective
40
+ # exist so far — see README's "Runtime method tracing" section for
41
+ # what's planned vs. implemented.
42
+ class APM
43
+ # One method call. `parent_span_id` is nil for the outermost traced
44
+ # call in a given `.trace` block. `exception_message` is nil unless
45
+ # `capture_exception_messages: true` was configured AND `redact_exception_message`
46
+ # didn't blank it out.
47
+ Span = Struct.new(
48
+ :trace_id,
49
+ :span_id,
50
+ :parent_span_id,
51
+ :class_name,
52
+ :method_name,
53
+ :file,
54
+ :line,
55
+ :started_at, # Float, Process.clock_gettime(Process::CLOCK_MONOTONIC) — for duration math
56
+ :wall_time, # Time — for correlating against logs/other tools
57
+ :duration_ms,
58
+ :status, # :ok | :error
59
+ :exception_class,
60
+ :exception_message,
61
+ :thread_id,
62
+ keyword_init: true
63
+ ) do
64
+ def to_h
65
+ super.transform_keys(&:to_s)
66
+ end
67
+ end
68
+
69
+ class << self
70
+ # Turns tracing on for the life of the process. Idempotent (later
71
+ # calls are no-ops) — same contract as QueryWatcher.enable!. Reads
72
+ # unset keyword args from Scryer.configuration.apm so
73
+ # `Scryer.configure { |c| c.apm.foo = ... }; Scryer::APM.enable!`
74
+ # works without repeating every option.
75
+ def enable!(
76
+ provider: nil,
77
+ instrumentation: nil,
78
+ include: nil,
79
+ exclude: nil,
80
+ sampling_rate: nil,
81
+ capture_exception_messages: nil,
82
+ logger: nil
83
+ )
84
+ return if @enabled
85
+
86
+ cfg = Scryer.configuration.apm
87
+ @provider = provider || cfg.provider || :new_relic
88
+ @instrumentation = instrumentation || cfg.instrumentation || :selective
89
+ @include = Array(include || cfg.include).map(&:to_s)
90
+ @exclude = Array(exclude || cfg.exclude).map(&:to_s) + DEFAULT_EXCLUDE
91
+ @sampling_rate = (sampling_rate || cfg.sampling_rate || 1.0).to_f.clamp(0.0, 1.0)
92
+ @capture_exception_messages = capture_exception_messages.nil? ? !!cfg.capture_exception_messages : capture_exception_messages
93
+ @logger = logger || default_logger
94
+ @instrumented = {} # {[class_name, method_name] => true} — dedupe guard
95
+ @enabled = true
96
+
97
+ case @instrumentation
98
+ when :off
99
+ # Nothing to install — enabled? is true (so .trace/.instrument
100
+ # calls don't raise) but no method is ever wrapped, so overhead
101
+ # is the cost of one `if` per call site that checks this mode,
102
+ # nothing more.
103
+ when :selective
104
+ @include.each { |name| instrument_by_name(name) }
105
+ when :discovery, :deep_trace
106
+ raise ArgumentError,
107
+ "Scryer::APM instrumentation: #{@instrumentation.inspect} is not implemented yet " \
108
+ "(only :off and :selective are). See README's \"Runtime method tracing\" section."
109
+ else
110
+ raise ArgumentError, "Scryer::APM instrumentation: unknown mode #{@instrumentation.inspect}"
111
+ end
112
+
113
+ warn_if_provider_unavailable
114
+ end
115
+
116
+ def enabled?
117
+ !!@enabled
118
+ end
119
+
120
+ # Test-only: undoes .enable! bookkeeping. Does NOT un-prepend already
121
+ #-installed modules (Ruby has no supported way to do that) — tests
122
+ # that need a truly clean slate use a fresh, disposable class per
123
+ # example instead of instrumenting a shared one twice.
124
+ def disable!
125
+ @enabled = false
126
+ @instrumented = {}
127
+ @on_span = nil
128
+ end
129
+
130
+ def instrumentation_mode
131
+ @instrumentation
132
+ end
133
+
134
+ # Opens a new trace scope — call this once per unit of work (an HTTP
135
+ # request, a Sidekiq job, a rake task). Every span created by an
136
+ # instrumented method call inside the block shares this trace_id and
137
+ # nests under whatever span is currently open on this thread, via a
138
+ # thread-local stack (so nested `.trace` calls — a job that itself
139
+ # calls another instrumented method — behave correctly: the inner
140
+ # scope's spans still get the outer trace_id if one is already open,
141
+ # rather than starting a disconnected new trace).
142
+ def trace(trace_id: nil)
143
+ return yield unless enabled? && @instrumentation != :off
144
+
145
+ already_in_trace = Thread.current[TRACE_ID_KEY]
146
+ Thread.current[TRACE_ID_KEY] ||= trace_id || SecureRandom.hex(16)
147
+ Thread.current[SAMPLED_KEY] = rand < @sampling_rate if Thread.current[SAMPLED_KEY].nil?
148
+ Thread.current[SPAN_STACK_KEY] ||= []
149
+ yield
150
+ ensure
151
+ unless already_in_trace
152
+ Thread.current[TRACE_ID_KEY] = nil
153
+ Thread.current[SAMPLED_KEY] = nil
154
+ Thread.current[SPAN_STACK_KEY] = nil
155
+ end
156
+ end
157
+
158
+ def current_trace_id
159
+ Thread.current[TRACE_ID_KEY]
160
+ end
161
+
162
+ def sampled?
163
+ !!Thread.current[SAMPLED_KEY]
164
+ end
165
+
166
+ # Resolves a "ClassName" or "Module::ClassName" string to a constant
167
+ # and instruments every method defined directly on it (public,
168
+ # protected, and private — visibility is preserved, see
169
+ # build_instrumentation_module). Silently skips (with a logged
170
+ # warning, not an exception — a typo in `include` shouldn't crash
171
+ # boot) names that don't resolve or that match `exclude`.
172
+ def instrument_by_name(name)
173
+ return if excluded?(name)
174
+
175
+ klass = Object.const_get(name)
176
+ methods = klass.instance_methods(false) + klass.private_instance_methods(false) +
177
+ klass.protected_instance_methods(false)
178
+ methods.uniq.each { |m| instrument_method(klass, m) }
179
+ rescue NameError => e
180
+ @logger.warn("[Scryer::APM] include: #{name.inspect} did not resolve to a constant (#{e.message}) — skipped")
181
+ end
182
+
183
+ # One bad method must never take out the rest of `include` — this is
184
+ # the boundary between "a typo/edge case in config" and "the app
185
+ # fails to boot", so it catches broadly (StandardError, not just the
186
+ # NameError instrument_by_name's own constant-resolution guards for)
187
+ # and skips just this one method, logged, rather than propagating.
188
+ def instrument_method(klass, method_name)
189
+ key = [klass.name, method_name]
190
+ return if @instrumented[key]
191
+
192
+ original = klass.instance_method(method_name)
193
+ location = original.source_location
194
+ visibility = method_visibility(klass, method_name)
195
+ mod = build_instrumentation_module(klass, method_name, location, visibility)
196
+ klass.prepend(mod)
197
+ @instrumented[key] = true
198
+ rescue StandardError => e
199
+ @logger.warn("[Scryer::APM] failed to instrument #{klass}##{method_name} (#{e.class}: #{e.message}) — skipped")
200
+ end
201
+
202
+ def instrumented?(klass, method_name)
203
+ !!@instrumented[[klass.name, method_name]]
204
+ end
205
+
206
+ private
207
+
208
+ TRACE_ID_KEY = :scryer_apm_trace_id
209
+ SAMPLED_KEY = :scryer_apm_sampled
210
+ SPAN_STACK_KEY = :scryer_apm_span_stack
211
+
212
+ # Never instrumented even if named explicitly in `include` — Scryer's
213
+ # own code (would recurse into itself the moment .enable! ran), and
214
+ # the framework namespaces most likely to be typo'd into `include`
215
+ # by mistake (each is *also* excludable/addable by the host app via
216
+ # its own `exclude`/`include`, this is just a safe floor).
217
+ DEFAULT_EXCLUDE = %w[Scryer:: ActiveRecord:: ActionController:: ActionDispatch:: Rails::].freeze
218
+
219
+ def excluded?(name)
220
+ @exclude.any? { |pattern| name.start_with?(pattern) || name == pattern.sub(/::\z/, "") }
221
+ end
222
+
223
+ def method_visibility(klass, method_name)
224
+ return :private if klass.private_method_defined?(method_name)
225
+ return :protected if klass.protected_method_defined?(method_name)
226
+
227
+ :public
228
+ end
229
+
230
+ # One freshly-built anonymous module per (class, method) pair,
231
+ # `prepend`ed onto the class — the same mechanism QueryWatcher uses
232
+ # on ActiveRecord::QueryMethods. `*args, **kwargs, &block` + `super`
233
+ # forwards positional args, keyword args, and blocks correctly on
234
+ # every Ruby version this gem supports (>= 2.7); the wrapped method's
235
+ # return value and any exception it raises both pass through
236
+ # unchanged. `send(:private, ...)`/`protected` after `define_method`
237
+ # restores the original visibility, since `define_method` always
238
+ # defines a public method by default.
239
+ def build_instrumentation_module(klass, method_name, location, visibility)
240
+ file, line = location
241
+ Module.new do
242
+ define_method(method_name) do |*args, **kwargs, &block|
243
+ unless Scryer::APM.enabled? && Scryer::APM.instrumentation_mode == :selective && Scryer::APM.sampled?
244
+ next super(*args, **kwargs, &block)
245
+ end
246
+
247
+ Scryer::APM.send(:call_instrumented, self.class, method_name, file, line) do
248
+ super(*args, **kwargs, &block)
249
+ end
250
+ end
251
+ send(visibility, method_name) unless visibility == :public
252
+ end
253
+ end
254
+
255
+ # The actual per-call work: build the Span, open a New Relic segment
256
+ # if one's available, run the real method, close everything out
257
+ # (success or exception) and push/pop the thread-local span stack so
258
+ # nested instrumented calls get the right parent_span_id.
259
+ def call_instrumented(klass, method_name, file, line)
260
+ stack = (Thread.current[SPAN_STACK_KEY] ||= [])
261
+ span = Span.new(
262
+ trace_id: current_trace_id || (Thread.current[TRACE_ID_KEY] = SecureRandom.hex(16)),
263
+ span_id: SecureRandom.hex(8),
264
+ parent_span_id: stack.last&.span_id,
265
+ class_name: klass.name,
266
+ method_name: method_name.to_s,
267
+ file: file,
268
+ line: line,
269
+ started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
270
+ wall_time: Time.now,
271
+ thread_id: Thread.current.object_id,
272
+ status: :ok
273
+ )
274
+ stack.push(span)
275
+ provider_segment = start_provider_segment(klass, method_name)
276
+
277
+ begin
278
+ yield
279
+ rescue Exception => e # rubocop-equivalent: intentional — must record then re-raise every exception class, not just StandardError
280
+ span.status = :error
281
+ span.exception_class = e.class.name
282
+ span.exception_message = redact_exception_message(e.message) if @capture_exception_messages
283
+ raise
284
+ ensure
285
+ span.duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - span.started_at) * 1000).round(3)
286
+ finish_provider_segment(provider_segment, span)
287
+ stack.pop
288
+ @on_span&.call(span)
289
+ end
290
+ end
291
+
292
+ # Dispatches to whichever provider is configured — only one provider
293
+ # is active per process (set once at .enable! time), so this is a
294
+ # simple case, not a fan-out to every provider at once.
295
+ def start_provider_segment(klass, method_name)
296
+ case @provider
297
+ when :new_relic then start_new_relic_segment(klass, method_name)
298
+ when :opentelemetry then start_open_telemetry_span(klass, method_name)
299
+ end
300
+ end
301
+
302
+ def finish_provider_segment(segment, span)
303
+ case @provider
304
+ when :new_relic then finish_new_relic_segment(segment, span)
305
+ when :opentelemetry then finish_open_telemetry_span(segment, span)
306
+ end
307
+ end
308
+
309
+ # `NewRelic::Agent::Tracer.start_segment`/`Segment#finish` is the
310
+ # agent's own public, documented API for creating a custom segment
311
+ # that nests under whatever transaction/segment is already open —
312
+ # this is how the agent's own instrumentation (and `add_method_tracer`
313
+ # under the hood) creates segments, so it's the supported integration
314
+ # path, not an undocumented mechanism. Soft-detected: if newrelic_rpm
315
+ # isn't loaded, or isn't inside an active transaction, this is a
316
+ # no-op and the instrumented method still runs normally — APM
317
+ # visibility is never allowed to break the app it's watching.
318
+ def start_new_relic_segment(klass, method_name)
319
+ return nil unless @provider == :new_relic && defined?(NewRelic::Agent::Tracer)
320
+
321
+ NewRelic::Agent::Tracer.start_segment(name: "Custom/#{klass.name}/#{method_name}")
322
+ rescue StandardError => e
323
+ @logger.warn("[Scryer::APM] New Relic segment start failed: #{e.message}") if @logger
324
+ nil
325
+ end
326
+
327
+ def finish_new_relic_segment(segment, span)
328
+ return unless segment
329
+
330
+ segment.notice_error(build_reportable_exception(span)) if span.status == :error && span.exception_class
331
+ segment.finish
332
+ rescue StandardError => e
333
+ @logger&.warn("[Scryer::APM] New Relic segment finish failed: #{e.message}")
334
+ end
335
+
336
+ # `OpenTelemetry.tracer_provider.tracer(...).start_span` + explicit
337
+ # `OpenTelemetry::Context.attach`/`.detach` is the SDK's own public,
338
+ # documented manual-span API (used whenever the block form,
339
+ # `Tracer#in_span`, doesn't fit — exactly this case, since
340
+ # call_instrumented's own begin/rescue/ensure already owns the
341
+ # start/finish lifecycle). `Context.attach` makes this span the
342
+ # "current" one so the *next* nested instrumented call's
343
+ # `tracer.start_span` — which defaults `with_parent:` to
344
+ # `Context.current` — picks it up as its parent automatically; no
345
+ # manual parent bookkeeping needed beyond attach/detach being
346
+ # correctly paired, same shape as the thread-local span stack this
347
+ # module already keeps for its own Span struct. Soft-detected: if
348
+ # the opentelemetry-sdk gem isn't loaded, this is a no-op and the
349
+ # instrumented method still runs normally, same contract as the New
350
+ # Relic path.
351
+ def start_open_telemetry_span(klass, method_name)
352
+ return nil unless @provider == :opentelemetry && defined?(OpenTelemetry::Trace)
353
+
354
+ tracer = OpenTelemetry.tracer_provider.tracer("scryer", Scryer::VERSION)
355
+ otel_span = tracer.start_span("#{klass.name}##{method_name}", kind: :internal)
356
+ token = OpenTelemetry::Context.attach(OpenTelemetry::Trace.context_with_span(otel_span))
357
+ [otel_span, token]
358
+ rescue StandardError => e
359
+ @logger.warn("[Scryer::APM] OpenTelemetry span start failed: #{e.message}") if @logger
360
+ nil
361
+ end
362
+
363
+ def finish_open_telemetry_span(segment, span)
364
+ return unless segment
365
+
366
+ otel_span, token = segment
367
+ if span.status == :error && span.exception_class
368
+ otel_span.record_exception(build_reportable_exception(span))
369
+ otel_span.status = OpenTelemetry::Trace::Status.error("#{span.exception_class}: #{span.exception_message || 'error'}")
370
+ end
371
+ otel_span.finish
372
+ rescue StandardError => e
373
+ @logger&.warn("[Scryer::APM] OpenTelemetry span finish failed: #{e.message}")
374
+ ensure
375
+ OpenTelemetry::Context.detach(token) if token
376
+ end
377
+
378
+ # NewRelic::Agent::Segment#notice_error wants a real exception object,
379
+ # not just a class name string — this reconstructs a minimal one
380
+ # carrying only what Span itself already decided to keep (never the
381
+ # original exception object, which could hold references to whatever
382
+ # arguments/state the traced method closed over).
383
+ def build_reportable_exception(span)
384
+ klass = Object.const_get(span.exception_class)
385
+ klass.exception(span.exception_message || span.exception_class)
386
+ rescue StandardError
387
+ StandardError.new(span.exception_class)
388
+ end
389
+
390
+ # Best-effort only, not exhaustive (see README limitations): masks
391
+ # substrings that look like tokens/secrets (long hex/base64-ish
392
+ # runs, email addresses) before an exception message is ever kept.
393
+ # Off entirely unless capture_exception_messages: true — the safer
394
+ # default is to keep the exception *class* only.
395
+ def redact_exception_message(message)
396
+ return nil unless message
397
+
398
+ message
399
+ .gsub(/[A-Za-z0-9_\-]{24,}/, "[REDACTED]")
400
+ .gsub(/[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}/, "[REDACTED_EMAIL]")
401
+ end
402
+
403
+ def warn_if_provider_unavailable
404
+ return unless @instrumentation == :selective
405
+
406
+ case @provider
407
+ when :new_relic
408
+ return if defined?(NewRelic::Agent::Tracer)
409
+
410
+ @logger.warn(
411
+ "[Scryer::APM] provider: :new_relic but NewRelic::Agent isn't loaded — spans will be " \
412
+ "traced (available via Scryer::APM.on_span) but not exported anywhere."
413
+ )
414
+ when :opentelemetry
415
+ return if defined?(OpenTelemetry::Trace)
416
+
417
+ @logger.warn(
418
+ "[Scryer::APM] provider: :opentelemetry but the opentelemetry-sdk gem isn't loaded — " \
419
+ "spans will be traced (available via Scryer::APM.on_span) but not exported anywhere."
420
+ )
421
+ end
422
+ end
423
+
424
+ def default_logger
425
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
426
+ Rails.logger
427
+ else
428
+ require "logger"
429
+ Logger.new($stdout)
430
+ end
431
+ end
432
+
433
+ public
434
+
435
+ # Registers a callback invoked with every completed Span, regardless
436
+ # of provider — the hook the test suite uses to assert on spans
437
+ # directly, and available to any host app that wants its own
438
+ # sink (a log line, a metrics counter) alongside/instead of New Relic.
439
+ def on_span(&block)
440
+ @on_span = block
441
+ end
442
+ end
443
+
444
+ # `use Scryer::APM::Middleware` opens one trace per request, same
445
+ # per-request-scoping rationale as Scryer::QueryWatcher::Middleware.
446
+ class Middleware
447
+ def initialize(app)
448
+ @app = app
449
+ end
450
+
451
+ def call(env)
452
+ result = nil
453
+ Scryer::APM.trace { result = @app.call(env) }
454
+ result
455
+ end
456
+ end
457
+ end
458
+ end
data/lib/scryer/ast.rb CHANGED
@@ -1,4 +1,6 @@
1
+ # frozen_string_literal: true
1
2
  require "ripper"
3
+ require "set"
2
4
 
3
5
  module Scryer
4
6
  # Small set of helpers for walking the S-expression tree that Ripper.sexp
@@ -322,6 +324,57 @@ module Scryer
322
324
  node[1][1]
323
325
  end
324
326
 
327
+ # Shared by MassAssignmentRule/IdorRule/MissingPolicyScopeRule — every
328
+ # one of them needs "is this bare constant receiver likely an
329
+ # ActiveRecord model" and, until this method existed, each rule
330
+ # answered it with its own copy of a purely name-based guess (an exact
331
+ # exclusion list of stdlib/gem constants with their own `.new`/`.find`/
332
+ # `.where`-shaped methods). That guess had a real, reported false
333
+ # positive: a plain Ruby service/command object — `Server.new(params)
334
+ # .call`, the well-known "interactor" pattern — looks syntactically
335
+ # identical to `Order.new(params)`, and no fixed exclusion list can
336
+ # anticipate every project's own naming convention for that pattern.
337
+ #
338
+ # `known_models`/`known_non_models` (both Sets of unqualified, last-
339
+ # segment class names — see Scanner#call, which builds them once per
340
+ # scan by walking every `class X < Y` declaration across every scanned
341
+ # file) give this a real, Brakeman-like signal instead of a guess,
342
+ # whenever the receiver's class is actually declared somewhere in the
343
+ # project: `known_models` wins outright (a model genuinely named
344
+ # e.g. `Command` still gets flagged), and `known_non_models` (a class
345
+ # declared with literally no superclass, or with a superclass matching
346
+ # NON_MODEL_SUPERCLASS_PATTERN below — neither shape an ActiveRecord
347
+ # model ever has) excludes it outright regardless of name. Only when
348
+ # neither set has an answer (the class isn't declared anywhere Scryer
349
+ # scanned — a gem-provided constant, or `c.dirs` not covering it) does
350
+ # this fall back to the same purely-name-based guessing as before:
351
+ # NON_MODEL_RECEIVER_NAMES (stdlib/gem constants) and
352
+ # NON_MODEL_RECEIVER_SUFFIXES (a project's own service/command objects,
353
+ # covering the common "Service"/"Interactor"/etc. conventions when the
354
+ # class itself wasn't visible to this scan). Defaults (assume it likely
355
+ # *is* a model) when none of the above says otherwise — same
356
+ # false-positive-favoring direction as every other heuristic in this
357
+ # gem: a missed real mass-assignment/IDOR finding is worse than an
358
+ # occasional nudge to double-check a service object.
359
+ NON_MODEL_RECEIVER_NAMES = %w[
360
+ Struct OpenStruct Data Class Module BCrypt OpenSSL Net URI Digest
361
+ JSON YAML Marshal String Array Hash Integer Float Symbol Comparable
362
+ Enumerable File Dir
363
+ ].freeze
364
+
365
+ NON_MODEL_RECEIVER_SUFFIXES = %w[Service Server Interactor Operation Command UseCase].freeze
366
+
367
+ EMPTY_SET = Set.new.freeze
368
+
369
+ def likely_model_name?(const_name, known_models: EMPTY_SET, known_non_models: EMPTY_SET)
370
+ return true if known_models.include?(const_name)
371
+ return false if known_non_models.include?(const_name)
372
+ return false if NON_MODEL_RECEIVER_NAMES.include?(const_name)
373
+ return false if NON_MODEL_RECEIVER_SUFFIXES.any? { |suffix| const_name.end_with?(suffix) }
374
+
375
+ true
376
+ end
377
+
325
378
  # True if `node` is `params`, `params[:x]`, or contains such a reference
326
379
  # anywhere in its subtree — the shared "does this touch raw request data"
327
380
  # check behind mass assignment, IDOR, SSRF, and path traversal detection.
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  # Runtime companion to the static `idor`/`missing_authorization`/
3
4
  # `missing_policy_scope` rules — those can only ever say "no call to a
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "digest"
2
3
  require "json"
3
4
  require "set"
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "ripper"
2
3
 
3
4
  module Scryer
data/lib/scryer/cli.rb CHANGED
@@ -1,13 +1,14 @@
1
+ # frozen_string_literal: true
1
2
  require "optparse"
2
3
  require "fileutils"
3
- require "shellwords"
4
+ require "open3"
4
5
 
5
6
  module Scryer
6
7
  # Backs the `scryer` executable (see exe/scryer) — a standalone,
7
8
  # Rails-free way to run a scan, mirroring `brakeman -o report.json`.
8
- # Deliberately separate from lib/scryer.rb: OptionParser/Shellwords are
9
- # only needed for this CLI entry point, not when the gem is required
10
- # inside a host app.
9
+ # Deliberately separate from lib/scryer.rb: OptionParser/Open3 are only
10
+ # needed for this CLI entry point, not when the gem is required inside a
11
+ # host app.
11
12
  class CLI
12
13
  EXTENSION_FORMATS = { ".json" => "json", ".html" => "html", ".htm" => "html", ".csv" => "csv", ".sarif" => "sarif" }.freeze
13
14
 
@@ -85,9 +86,9 @@ module Scryer
85
86
  renderer = ReportRenderer.new(
86
87
  result: result,
87
88
  project_name: options[:project_name] || File.basename(root),
88
- release_label: git(root, "describe --tags --always"),
89
- git_commit_sha: git(root, "rev-parse HEAD"),
90
- git_branch: options[:branch] || git(root, "rev-parse --abbrev-ref HEAD"),
89
+ release_label: git(root, "describe", "--tags", "--always"),
90
+ git_commit_sha: git(root, "rev-parse", "HEAD"),
91
+ git_branch: options[:branch] || git(root, "rev-parse", "--abbrev-ref", "HEAD"),
91
92
  dependency_findings: dependency_findings
92
93
  )
93
94
 
@@ -192,7 +193,6 @@ module Scryer
192
193
  ]
193
194
 
194
195
  divider = paint("─" * 32, :gray)
195
- score = renderer.security_score
196
196
  @stdout.puts ""
197
197
  @stdout.puts paint("Scryer Audit — #{result.files_scanned} files scanned", :bold)
198
198
  @stdout.puts divider
@@ -203,7 +203,10 @@ module Scryer
203
203
  @stdout.puts ""
204
204
  end
205
205
  clean_rate = renderer.rules_clean_rate
206
- @stdout.puts "Security Score: #{score["score"]}/100 (#{paint_grade(score["grade"], score["grade"])})"
206
+ @stdout.puts score_row("Security Score", renderer.security_score)
207
+ @stdout.puts score_row("Performance Score", renderer.performance_score)
208
+ @stdout.puts score_row("Style Score", renderer.style_score)
209
+ @stdout.puts(ran_deps ? score_row("Dependency Score", renderer.dependency_score) : "#{"Dependency Score".ljust(20)}skipped (--no-deps)")
207
210
  @stdout.puts "Checks: #{clean_rate["clean"]}/#{clean_rate["total"]} rules clean (#{clean_rate["percent"]}%)"
208
211
  @stdout.puts ""
209
212
  rows.each { |label, count| @stdout.puts summary_row(label, count) }
@@ -220,6 +223,10 @@ module Scryer
220
223
  "#{label.ljust(14)}#{value.rjust(20)}"
221
224
  end
222
225
 
226
+ def score_row(label, score)
227
+ "#{label.ljust(20)}#{score["score"]}/100 (#{paint_grade(score["grade"], score["grade"])})"
228
+ end
229
+
223
230
  # The categories above are counted separately, but nothing else ranks
224
231
  # across them — this is what actually backs "tells you what to fix
225
232
  # first" rather than just splitting findings into four buckets. Same
@@ -917,8 +924,9 @@ module Scryer
917
924
  [File.join(dir, "scryer_report.json"), File.join(dir, "scryer_report.html")]
918
925
  end
919
926
 
920
- def git(root, cmd)
921
- output = `git -C #{Shellwords.escape(root)} #{cmd} 2>/dev/null`.strip
927
+ def git(root, *cmd)
928
+ output, = Open3.capture3("git", "-C", root, *cmd)
929
+ output = output.strip
922
930
  output.empty? ? nil : output
923
931
  rescue StandardError
924
932
  nil
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  # Hand-rolled ANSI color/style helper for console output — deliberately
3
4
  # not a gem dependency (`pastel`, `colorize`, ...), consistent with
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "json"
2
3
  require "date"
3
4
 
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "open3"
2
3
 
3
4
  module Scryer
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "set"
2
3
 
3
4
  module Scryer
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  # A single reported issue. `suggested_fix` is always a human-reviewable text
3
4
  # explanation + example patch, never something auto-applied — see the
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "set"
2
3
 
3
4
  module Scryer
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "ripper"
2
3
 
3
4
  module Scryer
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "set"
2
3
  require "ripper"
3
4
 
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "ripper"
2
3
 
3
4
  module Scryer