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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +129 -10
- data/README.md +9 -4
- data/docs/architecture.md +45 -36
- data/docs/rails-integration.md +182 -1
- data/docs/rules.md +8 -1
- data/docs/usage.md +5 -2
- data/lib/generators/scryer/install_generator.rb +1 -0
- data/lib/generators/scryer/templates/scryer_initializer.rb +22 -0
- data/lib/scryer/ai_client.rb +1 -0
- data/lib/scryer/ai_fix_suggester.rb +1 -0
- data/lib/scryer/apm.rb +458 -0
- data/lib/scryer/ast.rb +53 -0
- data/lib/scryer/authorization_watcher.rb +1 -0
- data/lib/scryer/baseline.rb +1 -0
- data/lib/scryer/cache_extractor.rb +1 -0
- data/lib/scryer/cli.rb +19 -11
- data/lib/scryer/colorizer.rb +1 -0
- data/lib/scryer/dependency_audit.rb +1 -0
- data/lib/scryer/dependency_fixer.rb +1 -0
- data/lib/scryer/duplicate_detector.rb +1 -0
- data/lib/scryer/finding.rb +1 -0
- data/lib/scryer/fix_runner.rb +1 -0
- data/lib/scryer/fix_verifier.rb +1 -0
- data/lib/scryer/mechanical_fixer.rb +1 -0
- data/lib/scryer/method_extractor.rb +1 -0
- data/lib/scryer/minitest.rb +1 -0
- data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +1 -0
- data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
- data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +2 -0
- data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +9 -1
- data/lib/scryer/query_extractor.rb +1 -0
- data/lib/scryer/query_watcher.rb +1 -0
- data/lib/scryer/railtie.rb +64 -4
- data/lib/scryer/report_renderer.rb +161 -99
- data/lib/scryer/rspec.rb +1 -0
- data/lib/scryer/rule.rb +15 -2
- data/lib/scryer/rule_set.rb +1 -0
- data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +1 -0
- data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +1 -0
- data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +1 -0
- data/lib/scryer/rules/authentication_bypass_rule.rb +1 -0
- data/lib/scryer/rules/command_injection_rule.rb +1 -0
- data/lib/scryer/rules/consider_all_requests_local_rule.rb +1 -0
- data/lib/scryer/rules/cors_misconfiguration_rule.rb +1 -0
- data/lib/scryer/rules/csrf_protection_rule.rb +1 -0
- data/lib/scryer/rules/dangerous_eval_rule.rb +117 -0
- data/lib/scryer/rules/force_ssl_rule.rb +1 -0
- data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +1 -0
- data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +1 -0
- data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +1 -0
- data/lib/scryer/rules/hardcoded_secret_rule.rb +1 -0
- data/lib/scryer/rules/host_authorization_disabled_rule.rb +1 -0
- data/lib/scryer/rules/idor_rule.rb +6 -14
- data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +1 -0
- data/lib/scryer/rules/job_raw_params_rule.rb +1 -0
- data/lib/scryer/rules/jwt_insecure_rule.rb +1 -0
- data/lib/scryer/rules/mass_assignment_rule.rb +10 -16
- data/lib/scryer/rules/missing_authorization_rule.rb +1 -0
- data/lib/scryer/rules/missing_policy_scope_rule.rb +2 -9
- data/lib/scryer/rules/open_redirect_rule.rb +1 -0
- data/lib/scryer/rules/path_traversal_rule.rb +1 -0
- data/lib/scryer/rules/security_headers_rule.rb +1 -0
- data/lib/scryer/rules/sql_injection_rule.rb +1 -0
- data/lib/scryer/rules/ssrf_rule.rb +1 -0
- data/lib/scryer/rules/unsafe_deserialization_rule.rb +1 -0
- data/lib/scryer/rules/verbose_production_log_level_rule.rb +1 -0
- data/lib/scryer/rules/weak_crypto_rule.rb +1 -0
- data/lib/scryer/rules/weak_session_cookie_rule.rb +1 -0
- data/lib/scryer/rules/xss_unsafe_html_rule.rb +1 -0
- data/lib/scryer/scanner.rb +126 -7
- data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
- data/lib/scryer/version.rb +2 -1
- data/lib/scryer.rb +49 -0
- data/lib/tasks/scryer.rake +8 -2
- metadata +3 -1
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.
|
data/lib/scryer/baseline.rb
CHANGED
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 "
|
|
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/
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
|
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 =
|
|
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
|
data/lib/scryer/colorizer.rb
CHANGED
data/lib/scryer/finding.rb
CHANGED
data/lib/scryer/fix_runner.rb
CHANGED
data/lib/scryer/fix_verifier.rb
CHANGED