├── test
├── apps
│ └── rails6.1
│ │ ├── log
│ │ └── .keep
│ │ ├── tmp
│ │ ├── .keep
│ │ └── pids
│ │ │ └── .keep
│ │ ├── storage
│ │ └── .keep
│ │ ├── vendor
│ │ └── .keep
│ │ ├── lib
│ │ ├── assets
│ │ │ └── .keep
│ │ └── tasks
│ │ │ └── .keep
│ │ ├── public
│ │ ├── favicon.ico
│ │ ├── apple-touch-icon.png
│ │ ├── apple-touch-icon-precomposed.png
│ │ ├── robots.txt
│ │ ├── 500.html
│ │ ├── 422.html
│ │ └── 404.html
│ │ ├── .ruby-version
│ │ ├── app
│ │ ├── assets
│ │ │ ├── images
│ │ │ │ └── .keep
│ │ │ ├── config
│ │ │ │ └── manifest.js
│ │ │ └── stylesheets
│ │ │ │ └── application.css
│ │ ├── models
│ │ │ ├── concerns
│ │ │ │ └── .keep
│ │ │ ├── post.rb
│ │ │ └── application_record.rb
│ │ ├── controllers
│ │ │ ├── concerns
│ │ │ │ └── .keep
│ │ │ ├── application_controller.rb
│ │ │ └── welcome_controller.rb
│ │ ├── views
│ │ │ ├── layouts
│ │ │ │ ├── mailer.text.erb
│ │ │ │ ├── mailer.html.erb
│ │ │ │ └── application.html.erb
│ │ │ └── welcome
│ │ │ │ └── index.html.erb
│ │ ├── helpers
│ │ │ ├── welcome_helper.rb
│ │ │ └── application_helper.rb
│ │ ├── channels
│ │ │ └── application_cable
│ │ │ │ ├── channel.rb
│ │ │ │ └── connection.rb
│ │ ├── mailers
│ │ │ └── application_mailer.rb
│ │ ├── javascript
│ │ │ ├── channels
│ │ │ │ ├── index.js
│ │ │ │ └── consumer.js
│ │ │ └── packs
│ │ │ │ └── application.js
│ │ └── jobs
│ │ │ └── application_job.rb
│ │ ├── config
│ │ ├── spring.rb
│ │ ├── environment.rb
│ │ ├── routes.rb
│ │ ├── initializers
│ │ │ ├── mime_types.rb
│ │ │ ├── application_controller_renderer.rb
│ │ │ ├── cookies_serializer.rb
│ │ │ ├── filter_parameter_logging.rb
│ │ │ ├── permissions_policy.rb
│ │ │ ├── wrap_parameters.rb
│ │ │ ├── backtrace_silencers.rb
│ │ │ ├── assets.rb
│ │ │ ├── inflections.rb
│ │ │ └── content_security_policy.rb
│ │ ├── boot.rb
│ │ ├── cable.yml
│ │ ├── credentials.yml.enc
│ │ ├── database.yml
│ │ ├── application.rb
│ │ ├── locales
│ │ │ └── en.yml
│ │ ├── storage.yml
│ │ ├── puma.rb
│ │ └── environments
│ │ │ ├── test.rb
│ │ │ ├── development.rb
│ │ │ └── production.rb
│ │ ├── bin
│ │ ├── rake
│ │ ├── rails
│ │ ├── spring
│ │ ├── yarn
│ │ └── setup
│ │ ├── config.ru
│ │ ├── Rakefile
│ │ ├── package.json
│ │ ├── .gitattributes
│ │ ├── db
│ │ └── seeds.rb
│ │ ├── README.md
│ │ ├── .gitignore
│ │ ├── Gemfile
│ │ └── Gemfile.lock
├── test_helper.rb
├── spektr_test.rb
└── spektr
│ ├── targets
│ ├── routes_test.rb
│ ├── view_test.rb
│ ├── base_test.rb
│ └── controller_test.rb
│ ├── app_test.rb
│ └── checks
│ ├── xss_test.rb
│ ├── send_test.rb
│ ├── cookie_serialization_test.rb
│ ├── basic_auth_test.rb
│ ├── file_access_test.rb
│ ├── file_disclosure_test.rb
│ ├── digest_dos_test.rb
│ ├── evaluations_test.rb
│ ├── content_tag_xss_test.rb
│ ├── basic_auth_timing_test.rb
│ ├── dynamic_finders_test.rb
│ ├── detailed_exceptions_test.rb
│ ├── json_entity_escape_test.rb
│ ├── json_parsing_test.rb
│ ├── default_routes_test.rb
│ ├── create_with_test.rb
│ ├── mass_assignment_test.rb
│ ├── link_to_href_test.rb
│ ├── sqli_test.rb
│ ├── command_injection_test.rb
│ ├── deserialize_test.rb
│ └── csrf_setting_test.rb
├── lib
├── spektr
│ ├── version.rb
│ ├── targets
│ │ ├── model.rb
│ │ ├── config.rb
│ │ ├── view.rb
│ │ ├── routes.rb
│ │ ├── controller.rb
│ │ └── base.rb
│ ├── checks.rb
│ ├── extractors
│ │ ├── calls.rb
│ │ └── methods.rb
│ ├── checks
│ │ ├── header_dos.rb
│ │ ├── i18n_xss.rb
│ │ ├── csrf.rb
│ │ ├── basic_auth_timing.rb
│ │ ├── cookie_serialization.rb
│ │ ├── send.rb
│ │ ├── csrf_setting.rb
│ │ ├── evaluation.rb
│ │ ├── basic_auth.rb
│ │ ├── file_disclosure.rb
│ │ ├── dynamic_finders.rb
│ │ ├── json_encoding.rb
│ │ ├── create_with.rb
│ │ ├── detailed_exceptions.rb
│ │ ├── digest_dos.rb
│ │ ├── filter_skipping.rb
│ │ ├── json_entity_escape.rb
│ │ ├── file_access.rb
│ │ ├── mass_assignment.rb
│ │ ├── sqli.rb
│ │ ├── link_to_href.rb
│ │ ├── json_parsing.rb
│ │ ├── default_routes.rb
│ │ ├── deserialize.rb
│ │ ├── command_injection.rb
│ │ ├── xss.rb
│ │ ├── content_tag_xss.rb
│ │ └── base.rb
│ ├── core_ext
│ │ └── string.rb
│ ├── warning.rb
│ ├── cli.rb
│ ├── erubi.rb
│ └── app.rb
└── spektr.rb
├── railsgoat-example.png
├── Gemfile
├── .travis.yml
├── .gitignore
├── CODE_OF_CONDUCT.md
├── bin
├── setup
├── spektr
└── console
├── Rakefile
├── CHANGELOG.md
├── .github
└── workflows
│ └── ci.yaml
├── LICENSE.txt
├── spektr.gemspec
├── Guardfile
└── README.md
/test/apps/rails6.1/log/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/tmp/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/storage/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/tmp/pids/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/vendor/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/lib/assets/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/lib/tasks/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/public/favicon.ico:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/.ruby-version:
--------------------------------------------------------------------------------
1 | 2.7.2
2 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/app/assets/images/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/app/models/concerns/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/app/controllers/concerns/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/public/apple-touch-icon-precomposed.png:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lib/spektr/version.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | VERSION = '0.5.0'
3 | end
4 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/app/views/layouts/mailer.text.erb:
--------------------------------------------------------------------------------
1 | <%= yield %>
2 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/app/models/post.rb:
--------------------------------------------------------------------------------
1 | class Post < ApplicationRecord
2 | end
--------------------------------------------------------------------------------
/test/apps/rails6.1/app/helpers/welcome_helper.rb:
--------------------------------------------------------------------------------
1 | module WelcomeHelper
2 | end
3 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/app/helpers/application_helper.rb:
--------------------------------------------------------------------------------
1 | module ApplicationHelper
2 | end
3 |
--------------------------------------------------------------------------------
/railsgoat-example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gregmolnar/spektr/HEAD/railsgoat-example.png
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | # Specify your gem's dependencies in spektr.gemspec
4 | gemspec
5 |
--------------------------------------------------------------------------------
/lib/spektr/targets/model.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | module Targets
3 | class Model < Base
4 | end
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/app/assets/config/manifest.js:
--------------------------------------------------------------------------------
1 | //= link_tree ../images
2 | //= link_directory ../stylesheets .css
3 |
--------------------------------------------------------------------------------
/lib/spektr/targets/config.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | module Targets
3 | class Config < Base
4 | end
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | ---
2 | language: ruby
3 | cache: bundler
4 | rvm:
5 | - 2.7.2
6 | before_install: gem install bundler -v 2.1.4
7 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/app/models/application_record.rb:
--------------------------------------------------------------------------------
1 | class ApplicationRecord < ActiveRecord::Base
2 | self.abstract_class = true
3 | end
4 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/public/robots.txt:
--------------------------------------------------------------------------------
1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
2 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/app/channels/application_cable/channel.rb:
--------------------------------------------------------------------------------
1 | module ApplicationCable
2 | class Channel < ActionCable::Channel::Base
3 | end
4 | end
5 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config/spring.rb:
--------------------------------------------------------------------------------
1 | Spring.watch(
2 | ".ruby-version",
3 | ".rbenv-vars",
4 | "tmp/restart.txt",
5 | "tmp/caching-dev.txt"
6 | )
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.bundle/
2 | /.yardoc
3 | /_yardoc/
4 | /coverage/
5 | /doc/
6 | /pkg/
7 | /spec/reports/
8 | /tmp/
9 | .byebug_history
10 | Gemfile.lock
11 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | Same as Ruby: [https://www.ruby-lang.org/en/conduct/](https://www.ruby-lang.org/en/conduct/)
4 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/app/channels/application_cable/connection.rb:
--------------------------------------------------------------------------------
1 | module ApplicationCable
2 | class Connection < ActionCable::Connection::Base
3 | end
4 | end
5 |
--------------------------------------------------------------------------------
/bin/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euo pipefail
3 | IFS=$'\n\t'
4 | set -vx
5 |
6 | bundle install
7 |
8 | # Do any other automated setup that you need to do here
9 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/app/mailers/application_mailer.rb:
--------------------------------------------------------------------------------
1 | class ApplicationMailer < ActionMailer::Base
2 | default from: 'from@example.com'
3 | layout 'mailer'
4 | end
5 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/bin/rake:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | load File.expand_path("spring", __dir__)
3 | require_relative "../config/boot"
4 | require "rake"
5 | Rake.application.run
6 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config/environment.rb:
--------------------------------------------------------------------------------
1 | # Load the Rails application.
2 | require_relative "application"
3 |
4 | # Initialize the Rails application.
5 | Rails.application.initialize!
6 |
--------------------------------------------------------------------------------
/bin/spektr:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | $:.unshift "#{File.expand_path(File.dirname(__FILE__))}/../lib"
3 | $VERBOSE = nil
4 | require 'spektr'
5 | require 'spektr/cli'
6 |
7 | Spektr::Cli.new.parse.scan
8 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/app/controllers/application_controller.rb:
--------------------------------------------------------------------------------
1 | class ApplicationController < ActionController::Base
2 | http_basic_authenticate_with name: "dhh", password: "secret", except: :index
3 | end
4 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config.ru:
--------------------------------------------------------------------------------
1 | # This file is used by Rack-based servers to start the application.
2 |
3 | require_relative "config/environment"
4 |
5 | run Rails.application
6 | Rails.application.load_server
7 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config/routes.rb:
--------------------------------------------------------------------------------
1 | Rails.application.routes.draw do
2 | get 'welcome/index'
3 | # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
4 | end
5 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config/initializers/mime_types.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Add new mime types for use in respond_to blocks:
4 | # Mime::Type.register "text/richtext", :rtf
5 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/bin/rails:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | load File.expand_path("spring", __dir__)
3 | APP_PATH = File.expand_path('../config/application', __dir__)
4 | require_relative "../config/boot"
5 | require "rails/commands"
6 |
--------------------------------------------------------------------------------
/test/test_helper.rb:
--------------------------------------------------------------------------------
1 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
2 | require "spektr"
3 |
4 | require "byebug"
5 | require "minitest/pride"
6 | require "minitest/autorun"
7 |
8 | RAILS_6_1_ROOT = File.join(__dir__, "apps", "rails6.1")
9 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require "bundler/gem_tasks"
2 | require "rake/testtask"
3 |
4 | Rake::TestTask.new(:test) do |t|
5 | t.libs << "test"
6 | t.libs << "lib"
7 | t.test_files = FileList["test/**/*_test.rb"]
8 | end
9 |
10 | task :default => :test
11 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config/boot.rb:
--------------------------------------------------------------------------------
1 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
2 |
3 | require "bundler/setup" # Set up gems listed in the Gemfile.
4 | require "bootsnap/setup" # Speed up boot time by caching expensive operations.
5 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/Rakefile:
--------------------------------------------------------------------------------
1 | # Add your own tasks in files placed in lib/tasks ending in .rake,
2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
3 |
4 | require_relative "config/application"
5 |
6 | Rails.application.load_tasks
7 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config/cable.yml:
--------------------------------------------------------------------------------
1 | development:
2 | adapter: async
3 |
4 | test:
5 | adapter: test
6 |
7 | production:
8 | adapter: redis
9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
10 | channel_prefix: rails6_1_production
11 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/app/javascript/channels/index.js:
--------------------------------------------------------------------------------
1 | // Load all the channels within this directory and all subdirectories.
2 | // Channel files must be named *_channel.js.
3 |
4 | const channels = require.context('.', true, /_channel\.js$/)
5 | channels.keys().forEach(channels)
6 |
--------------------------------------------------------------------------------
/test/spektr_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class SpektrTest < Minitest::Test
4 | def test_that_it_has_a_version_number
5 | refute_nil ::Spektr::VERSION
6 | end
7 |
8 | # def test_it_does_something_useful
9 | # assert false
10 | # end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/spektr/checks.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | def self.load(only = false)
4 | Checks.constants.select do |c|
5 | Checks.const_get(c).is_a?(Class) && (!only || only && only.to_s == c.to_s)
6 | end.map { |c| Checks.const_get(c) }
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "rails6-1",
3 | "private": true,
4 | "dependencies": {
5 | "@rails/ujs": "^6.0.0",
6 | "turbolinks": "^5.2.0",
7 | "@rails/activestorage": "^6.0.0",
8 | "@rails/actioncable": "^6.0.0"
9 | },
10 | "version": "0.1.0"
11 | }
12 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/app/controllers/welcome_controller.rb:
--------------------------------------------------------------------------------
1 | class WelcomeController < ApplicationController
2 | def index
3 | @welcome_message = params[:welcome_message]
4 | @attr = params[:attr]
5 | @safe_attr = "class"
6 | @post = Post.last
7 | @posts = Post.all
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config/initializers/application_controller_renderer.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # ActiveSupport::Reloader.to_prepare do
4 | # ApplicationController.renderer.defaults.merge!(
5 | # http_host: 'example.org',
6 | # https: false
7 | # )
8 | # end
9 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config/initializers/cookies_serializer.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Specify a serializer for the signed and encrypted cookie jars.
4 | # Valid options are :json, :marshal, and :hybrid.
5 | Rails.application.config.action_dispatch.cookies_serializer = :json
6 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/app/jobs/application_job.rb:
--------------------------------------------------------------------------------
1 | class ApplicationJob < ActiveJob::Base
2 | # Automatically retry jobs that encountered a deadlock
3 | # retry_on ActiveRecord::Deadlocked
4 |
5 | # Most jobs are safe to ignore if the underlying records are no longer available
6 | # discard_on ActiveJob::DeserializationError
7 | end
8 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/app/javascript/channels/consumer.js:
--------------------------------------------------------------------------------
1 | // Action Cable provides the framework to deal with WebSockets in Rails.
2 | // You can generate new channels where WebSocket features live using the `bin/rails generate channel` command.
3 |
4 | import { createConsumer } from "@rails/actioncable"
5 |
6 | export default createConsumer()
7 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/app/views/layouts/mailer.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
8 |
9 |
10 |
11 | <%= yield %>
12 |
13 |
14 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config/initializers/filter_parameter_logging.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Configure sensitive parameters which will be filtered from the log file.
4 | Rails.application.config.filter_parameters += [
5 | :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
6 | ]
7 |
--------------------------------------------------------------------------------
/test/spektr/targets/routes_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class RoutesTest < Minitest::Test
4 | def setup
5 | code = <<-CODE
6 | Rails3::Application.routes.draw do
7 | resources :products
8 | end
9 | CODE
10 |
11 | @routes = Spektr::Targets::Routes.new("routes.rb", code)
12 | end
13 |
14 | end
15 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/.gitattributes:
--------------------------------------------------------------------------------
1 | # See https://git-scm.com/docs/gitattributes for more about git attribute files.
2 |
3 | # Mark the database schema as having been generated.
4 | db/schema.rb linguist-generated
5 |
6 | # Mark the yarn lockfile as having been generated.
7 | yarn.lock linguist-generated
8 |
9 | # Mark any vendored files as having been vendored.
10 | vendor/* linguist-vendored
11 |
--------------------------------------------------------------------------------
/bin/console:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | require "bundler/setup"
4 | require "spektr"
5 |
6 | # You can add fixtures and/or initialization code here to make experimenting
7 | # with your gem easier. You can also use a different console, if you like.
8 |
9 | # (If you use this, don't forget to add pry to your Gemfile!)
10 | # require "pry"
11 | # Pry.start
12 |
13 | require "irb"
14 | IRB.start(__FILE__)
15 |
--------------------------------------------------------------------------------
/test/spektr/app_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class AppTest < Minitest::Test
4 | def test_it_loads_files
5 | app = Spektr::App.new(checks: Spektr::Checks.load, root: "./test/apps/rails6.1")
6 | app.load
7 | assert_equal 2, app.controllers.size
8 | assert_equal 2, app.models.size
9 | assert_equal 3, app.views.size
10 | assert_equal 0, app.lib_files.size
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/test/spektr/checks/xss_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class XssTest < Minitest::Test
4 | def setup
5 | @app = Spektr::App.new(root: RAILS_6_1_ROOT, checks: [Spektr::Checks::Xss])
6 | end
7 |
8 | def test_it_fails_with_unescaped_user_input
9 | @app.load
10 | @app.rails_version = Gem::Version.new "2.3.1"
11 | @app.scan!
12 | assert_equal 9, @app.warnings.size
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/db/seeds.rb:
--------------------------------------------------------------------------------
1 | # This file should contain all the record creation needed to seed the database with its default values.
2 | # The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).
3 | #
4 | # Examples:
5 | #
6 | # movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }])
7 | # Character.create(name: 'Luke', movie: movies.first)
8 |
--------------------------------------------------------------------------------
/test/spektr/targets/view_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class ViewTest < Minitest::Test
4 | def setup
5 | code = <<-CODE
6 | <%= content_tag :div, 'foo' %>
7 | CODE
8 |
9 | @view = Spektr::Targets::View.new("index.html.erb", code)
10 | end
11 |
12 | def test_it_finds_call
13 | calls = @view.find_calls :content_tag
14 | refute_empty calls
15 | assert_equal 1, calls.size
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config/initializers/permissions_policy.rb:
--------------------------------------------------------------------------------
1 | # Define an application-wide HTTP permissions policy. For further
2 | # information see https://developers.google.com/web/updates/2018/06/feature-policy
3 | #
4 | # Rails.application.config.permissions_policy do |f|
5 | # f.camera :none
6 | # f.gyroscope :none
7 | # f.microphone :none
8 | # f.usb :none
9 | # f.fullscreen :self
10 | # f.payment :self, "https://secure.example.com"
11 | # end
12 |
--------------------------------------------------------------------------------
/lib/spektr/extractors/calls.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | module Extractors
3 | class Calls < Prism::Visitor
4 | attr_accessor :result
5 |
6 | def initialize(name:)
7 | @name = name
8 | @result = []
9 | end
10 |
11 | def call(ast)
12 | ast.value.accept(self)
13 | self
14 | end
15 |
16 | def visit_call_node(node)
17 | @result << node if node.name == @name
18 | super
19 | end
20 | end
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config/credentials.yml.enc:
--------------------------------------------------------------------------------
1 | E9pcEgi2EkiWodN5MioGDgDSvZJ2ELSu7+2SkeI8jEnj3CEOp3C5GhlY1VcM6KHH+G1pnnMI+MJfe94GQWwdbYcLMzlfpOIDQVIxRG4xJYuT0zQu3NliIlSasjANk3xJfHt6iw1cmBtpL8ac1g4EDwa+PSXiyVR5HJOWhf9JNgsTqRc9drx+JcwtWapdMMCyI+jtOyajV2xxaIX/wweLiP45/KnCAb5rNmS4zfwsCdv7PlAj9aVNd2Deeb+2zGFzldXL07i/T1povJe66mFjlcJ/fIfhVP+lWzMNwoJUAMS6vlGYcgxXdymkrrog75vHDOqXUP1XmUpy5hzSmb8c45+r6q/kFNBcnSW8OOtTZwHSk24tTLvPGp+8mYbYHPILRhQi3vCA7OTNFRK7TtItYUFYbuLS0su8QkEt--hpWMSbcLTkQ7mTaD--uTnawB2TbLIgwnnOGU7kUQ==
--------------------------------------------------------------------------------
/test/apps/rails6.1/README.md:
--------------------------------------------------------------------------------
1 | # README
2 |
3 | This README would normally document whatever steps are necessary to get the
4 | application up and running.
5 |
6 | Things you may want to cover:
7 |
8 | * Ruby version
9 |
10 | * System dependencies
11 |
12 | * Configuration
13 |
14 | * Database creation
15 |
16 | * Database initialization
17 |
18 | * How to run the test suite
19 |
20 | * Services (job queues, cache servers, search engines, etc.)
21 |
22 | * Deployment instructions
23 |
24 | * ...
25 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/app/views/layouts/application.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Rails61
5 |
6 | <%= csrf_meta_tags %>
7 | <%= csp_meta_tag %>
8 |
9 | <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
10 | <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
11 |
12 |
13 |
14 | <%= yield %>
15 |
16 |
17 |
--------------------------------------------------------------------------------
/test/spektr/checks/send_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class SendTest < Minitest::Test
4 | def setup
5 | @app = Spektr::App.new(checks: [Spektr::Checks::Send])
6 | end
7 |
8 | def test_it_fails_with_user_controller_value
9 | code = <<-CODE
10 | @content.send(params[:field])
11 | CODE
12 | controller = Spektr::Targets::Controller.new("blog_controller.rb", code)
13 | check = Spektr::Checks::Send.new(@app, controller)
14 | check.run
15 | assert_equal 1, @app.warnings.size
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/spektr/checks/header_dos.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class HeaderDos < Base
4 |
5 | def initialize(app, target)
6 | super
7 | @name = "HTTP MIME type header DoS (CVE-2013-6414)"
8 | @type = "Denial of Service"
9 | @targets = ["Spektr::Targets::Base"]
10 | end
11 |
12 | def run
13 | return unless super
14 | if app_version_between?("3.0.0", "3.2.15")
15 | warn! "root", self, nil, "CVE_2013_6414"
16 | end
17 | end
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/lib/spektr/core_ext/string.rb:
--------------------------------------------------------------------------------
1 | class String
2 | def blank?
3 | nil? || self == ""
4 | end
5 |
6 | def underscore
7 | camel_cased_word = self
8 | return camel_cased_word.to_s unless /[A-Z-]|::/.match?(camel_cased_word)
9 | word = camel_cased_word.to_s.gsub("::", "/")
10 | word.gsub!(/(?:(?<=([A-Za-z\d]))|\b)((?=a))(?=\b|[^a-z])/) { "#{$1 && '_' }#{$2.downcase}" }
11 | word.gsub!(/([A-Z]+)(?=[A-Z][a-z])|([a-z\d])(?=[A-Z])/) { ($1 || $2) << "_" }
12 | word.tr!("-", "_")
13 | word.downcase!
14 | word
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/bin/spring:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | if !defined?(Spring) && [nil, "development", "test"].include?(ENV["RAILS_ENV"])
3 | gem "bundler"
4 | require "bundler"
5 |
6 | # Load Spring without loading other gems in the Gemfile, for speed.
7 | Bundler.locked_gems&.specs&.find { |spec| spec.name == "spring" }&.tap do |spring|
8 | Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path
9 | gem "spring", spring.version
10 | require "spring/binstub"
11 | rescue Gem::LoadError
12 | # Ignore when Spring is not installed.
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/app/javascript/packs/application.js:
--------------------------------------------------------------------------------
1 | // This file is automatically compiled by Webpack, along with any other files
2 | // present in this directory. You're encouraged to place your actual application logic in
3 | // a relevant structure within app/javascript and only use these pack files to reference
4 | // that code so it'll be compiled.
5 |
6 | import Rails from "@rails/ujs"
7 | import Turbolinks from "turbolinks"
8 | import * as ActiveStorage from "@rails/activestorage"
9 | import "channels"
10 |
11 | Rails.start()
12 | Turbolinks.start()
13 | ActiveStorage.start()
14 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config/initializers/wrap_parameters.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # This file contains settings for ActionController::ParamsWrapper which
4 | # is enabled by default.
5 |
6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array.
7 | ActiveSupport.on_load(:action_controller) do
8 | wrap_parameters format: [:json]
9 | end
10 |
11 | # To enable root element in JSON for ActiveRecord objects.
12 | # ActiveSupport.on_load(:active_record) do
13 | # self.include_root_in_json = true
14 | # end
15 |
--------------------------------------------------------------------------------
/test/spektr/checks/cookie_serialization_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class CookieSerializationTest < Minitest::Test
4 | def test_it_fails_with_marshal
5 | code = <<-CODE
6 | Rails.application.config.action_dispatch.cookies_serializer = :marshal
7 | CODE
8 | app = Spektr::App.new(checks: [Spektr::Checks::CookieSerialization])
9 | initializer = Spektr::Targets::Base.new("cookies_serialization.rb", code)
10 | check = Spektr::Checks::CookieSerialization.new(app, initializer)
11 | check.run
12 | assert_equal 1, app.warnings.size
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config/initializers/backtrace_silencers.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces.
4 | # Rails.backtrace_cleaner.add_silencer { |line| /my_noisy_library/.match?(line) }
5 |
6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code
7 | # by setting BACKTRACE=1 before calling your invocation, like "BACKTRACE=1 ./bin/rails runner 'MyClass.perform'".
8 | Rails.backtrace_cleaner.remove_silencers! if ENV["BACKTRACE"]
9 |
--------------------------------------------------------------------------------
/lib/spektr/checks/i18n_xss.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class I18nXss < Base
4 |
5 | def initialize(app, target)
6 | super
7 | @name = "XSS in i18n (CVE-2013-4491)"
8 | @type = "Cross-Site Scripting"
9 | @targets = ["Spektr::Targets::Base"]
10 | end
11 |
12 | def run
13 | return unless super
14 | if app_version_between?("3.0.6", "3.2.15") || app_version_between?("4.0.0", "4.0.1")
15 | warn! "root", self, nil, "I18n has a Cross-Site Scripting vulnerability (CVE_2013_4491)"
16 | end
17 | end
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/bin/yarn:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | APP_ROOT = File.expand_path('..', __dir__)
3 | Dir.chdir(APP_ROOT) do
4 | yarn = ENV["PATH"].split(File::PATH_SEPARATOR).
5 | select { |dir| File.expand_path(dir) != __dir__ }.
6 | product(["yarn", "yarn.cmd", "yarn.ps1"]).
7 | map { |dir, file| File.expand_path(file, dir) }.
8 | find { |file| File.executable?(file) }
9 |
10 | if yarn
11 | exec yarn, *ARGV
12 | else
13 | $stderr.puts "Yarn executable was not detected in the system."
14 | $stderr.puts "Download Yarn at https://yarnpkg.com/en/docs/install"
15 | exit 1
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/test/spektr/checks/basic_auth_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class BasicAuthTest < Minitest::Test
4 |
5 | def setup
6 | end
7 |
8 | def test_it_fails_with_plaintext_password
9 | code = <<-CODE
10 | class ApplicationController
11 | http_basic_authenticate_with name: "dhh", password: "secret", except: :index
12 | end
13 | CODE
14 | app = Spektr::App.new(checks: [Spektr::Checks::BasicAuth])
15 | controller = Spektr::Targets::Controller.new("application_controller.rb", code)
16 | check = Spektr::Checks::BasicAuth.new(app, controller)
17 | check.run
18 | assert_equal 1, app.warnings.size
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/test/spektr/checks/file_access_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class FileAccessTest < Minitest::Test
4 | def setup
5 | @code = <<-CODE
6 | class ApplicationController
7 | def index
8 | File.open(params[:directory])
9 | end
10 | end
11 | CODE
12 | @app = Spektr::App.new(checks: [Spektr::Checks::FileAccess])
13 | @controller = Spektr::Targets::Controller.new("application_controller.rb", @code)
14 | @check = Spektr::Checks::FileAccess.new(@app, @controller)
15 | end
16 |
17 | def test_it_fails_with_user_supplied_value
18 | @check.run
19 | assert_equal 1, @app.warnings.size
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/test/spektr/checks/file_disclosure_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class FileDisclosureTest < Minitest::Test
4 |
5 | def test_it_fails_with_insecure_config
6 | code = <<-CODE
7 | Rails.application.configure do
8 | config.serve_static_assets = true
9 | end
10 | CODE
11 | app = Spektr::App.new(checks: [Spektr::Checks::FileDisclosure])
12 | app.rails_version = Gem::Version.new("4.0.0")
13 | config = Spektr::Targets::Base.new("production.rb", code)
14 | app.production_config = config
15 | check = Spektr::Checks::FileDisclosure.new(app, config)
16 | check.run
17 | assert_equal 1, app.warnings.size
18 | end
19 |
20 | end
21 |
--------------------------------------------------------------------------------
/test/spektr/checks/digest_dos_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class DigestDosTest < Minitest::Test
4 | def setup
5 | @code = <<-CODE
6 | class ApplicationController
7 | authenticate_or_request_with_http_digest
8 | end
9 | CODE
10 | @app = Spektr::App.new(checks: [Spektr::Checks::DigestDos])
11 | @app.rails_version = Gem::Version.new "3.0.1"
12 | @controller = Spektr::Targets::Controller.new("application_controller.rb", @code)
13 | @check = Spektr::Checks::DigestDos.new(@app, @controller)
14 | end
15 |
16 | def test_it_fails_with_affected_version
17 | @check.run
18 | assert_equal 1, @app.warnings.size
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config/initializers/assets.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Version of your assets, change this if you want to expire all your assets.
4 | Rails.application.config.assets.version = '1.0'
5 |
6 | # Add additional assets to the asset load path.
7 | # Rails.application.config.assets.paths << Emoji.images_path
8 | # Add Yarn node_modules folder to the asset load path.
9 | Rails.application.config.assets.paths << Rails.root.join('node_modules')
10 |
11 | # Precompile additional assets.
12 | # application.js, application.css, and all non-JS/CSS in the app/assets
13 | # folder are already added.
14 | # Rails.application.config.assets.precompile += %w( admin.js admin.css )
15 |
--------------------------------------------------------------------------------
/test/spektr/checks/evaluations_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class EvaluationTest < Minitest::Test
4 | def setup
5 | @code = <<-CODE
6 | class ApplicationController
7 | def index
8 | eval("whoami")
9 | eval(`ls \#{params[:directory]}`)
10 | instance_eval params[:code]
11 | end
12 | end
13 | CODE
14 | @app = Spektr::App.new(checks: [Spektr::Checks::Evaluation])
15 | @controller = Spektr::Targets::Controller.new("application_controller.rb", @code)
16 | @check = Spektr::Checks::Evaluation.new(@app, @controller)
17 | end
18 |
19 | def test_it_fails_with_user_supplied_value
20 | @check.run
21 | assert_equal 2, @app.warnings.size
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | ## Unreleased
4 |
5 | ## 0.5.0
6 |
7 | * change parser to Prism
8 |
9 | ## 0.4.1
10 |
11 | * fix core extension eager loading
12 |
13 | ## 0.4.0
14 |
15 | * make XSS check work without a Rails version
16 | * change parent class extraction to support Structs
17 | * fix parsing errors
18 |
19 | ## 0.3.4
20 |
21 | * Relax dependencies, to help with using spektr as a gem
22 | * Fix executable
23 |
24 | ## 0.3.3
25 |
26 | * Remove hard dependency of haml 5
27 |
28 | ## 0.3.2
29 |
30 | * Rescue from lib file parsing errors
31 |
32 | * Drop Active Support from dependencies
33 |
34 | * Improve Gemspec
35 |
36 | ## 0.3.0
37 |
38 | * Add support to ignore findings
39 |
40 | ## 0.2.0
41 |
42 | * add Slim support
43 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config/initializers/inflections.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Add new inflection rules using the following format. Inflections
4 | # are locale specific, and you may define rules for as many different
5 | # locales as you wish. All of these examples are active by default:
6 | # ActiveSupport::Inflector.inflections(:en) do |inflect|
7 | # inflect.plural /^(ox)$/i, '\1en'
8 | # inflect.singular /^(ox)en/i, '\1'
9 | # inflect.irregular 'person', 'people'
10 | # inflect.uncountable %w( fish sheep )
11 | # end
12 |
13 | # These inflection rules are supported but not enabled by default:
14 | # ActiveSupport::Inflector.inflections(:en) do |inflect|
15 | # inflect.acronym 'RESTful'
16 | # end
17 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config/database.yml:
--------------------------------------------------------------------------------
1 | # SQLite. Versions 3.8.0 and up are supported.
2 | # gem install sqlite3
3 | #
4 | # Ensure the SQLite 3 gem is defined in your Gemfile
5 | # gem 'sqlite3'
6 | #
7 | default: &default
8 | adapter: sqlite3
9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
10 | timeout: 5000
11 |
12 | development:
13 | <<: *default
14 | database: db/development.sqlite3
15 |
16 | # Warning: The database defined as "test" will be erased and
17 | # re-generated from your development database when you run "rake".
18 | # Do not set this db to the same as development or production.
19 | test:
20 | <<: *default
21 | database: db/test.sqlite3
22 |
23 | production:
24 | <<: *default
25 | database: db/production.sqlite3
26 |
--------------------------------------------------------------------------------
/lib/spektr/extractors/methods.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | module Extractors
3 | class Methods < Prism::Visitor
4 | attr_accessor :result
5 |
6 | def initialize(visibility: :all)
7 | @visibility = visibility
8 | @current_visibility = :public
9 | @result = []
10 | end
11 |
12 | def call(ast)
13 | ast.value.accept(self)
14 | self
15 | end
16 |
17 | def visit_call_node(node)
18 | @current_visibility = node.name if %i[private protected public].include?(node.name)
19 | super
20 | end
21 |
22 | def visit_def_node(node)
23 | @result << node if @visibility == :all || @current_visibility == @visibility
24 | super
25 | end
26 | end
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/test/spektr/checks/content_tag_xss_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class ContentTagXssTest < Minitest::Test
4 |
5 | def setup
6 | @app = Spektr::App.new(root: RAILS_6_1_ROOT, checks: [Spektr::Checks::ContentTagXss])
7 | end
8 |
9 | def test_it_fails_with_rails_2
10 | @app.load
11 | @app.rails_version = Gem::Version.new "2.3.1"
12 | @app.scan!
13 | assert_equal 4, @app.warnings.size
14 | end
15 |
16 | def test_it_fails_for_cve_2016_6316
17 | @app.load
18 | @app.rails_version = Gem::Version.new "3.0.0"
19 | @app.scan!
20 | assert_equal 2, @app.warnings.size
21 | end
22 |
23 | def test_it_fails_for_unsafe_hash_attribute
24 | @app.load
25 | @app.scan!
26 | assert_equal 1, @app.warnings.size
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/lib/spektr/checks/csrf.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class Csrf < Base
4 | def initialize(app, target)
5 | super
6 | @name = "CSRF token forgery vulnerability (CVE-2020-8166)"
7 | @type = "Cross-Site Request Forgery"
8 | @targets = ["Spektr::Targets::Base"]
9 | end
10 |
11 | def run
12 | # disable this
13 | return false
14 | return unless super
15 | cve_2020_8186_check
16 | end
17 |
18 | def cve_2020_8186_check
19 | if app_version_between?('0.0.0', '5.2.4.2') || app_version_between?('6.0.0', '6.0.3')
20 | warn! @target, self, nil, "Rails #{@app.rails_version} has a vulnerability that may allow CSRF token forgery"
21 | end
22 | end
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/lib/spektr/checks/basic_auth_timing.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class BasicAuthTiming < Base
4 |
5 | def initialize(app, target)
6 | super
7 | @name = "Timing attack in basic auth (CVE-2015-7576)"
8 | @type = "Timing attack"
9 | @targets = ["Spektr::Targets::Controller"]
10 | end
11 |
12 | def run
13 | return unless super
14 | if @target.find_calls(:http_basic_authenticate_with).any?
15 | warn! @target, self, @target.find_calls(:http_basic_authenticate_with).first.location, "Basic authentication in Rails #{@app.rails_version} is vulnerable to timing attacks."
16 | end
17 | end
18 |
19 | def version_affected
20 | Gem::Version.new("4.2.5")
21 | end
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/app/assets/stylesheets/application.css:
--------------------------------------------------------------------------------
1 | /*
2 | * This is a manifest file that'll be compiled into application.css, which will include all the files
3 | * listed below.
4 | *
5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's
6 | * vendor/assets/stylesheets directory can be referenced here using a relative path.
7 | *
8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10 | * files in this directory. Styles in this file should be added after the last require_* statement.
11 | * It is generally better to create a new file per style scope.
12 | *
13 | *= require_tree .
14 | *= require_self
15 | */
16 |
--------------------------------------------------------------------------------
/lib/spektr/checks/cookie_serialization.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class CookieSerialization < Base
4 |
5 | def initialize(app, target)
6 | super
7 | @name = "Unsafe deserialisation"
8 | @type = "Insecure Deserialization"
9 | @targets = ["Spektr::Targets::Base", "Spektr::Targets::Controller"]
10 | end
11 |
12 | def run
13 | return unless super
14 | calls = @target.find_calls(:cookies_serializer=)
15 | if calls.any?{ |call| full_receiver(call) == "Rails.application.config.action_dispatch" && call.arguments.arguments.first.unescaped == "marshal" }
16 | warn! @target, self, calls.first.location, "Marshal cookie serialization strategy can lead to remote code execution"
17 | end
18 | end
19 | end
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config/application.rb:
--------------------------------------------------------------------------------
1 | require_relative "boot"
2 |
3 | require "rails/all"
4 |
5 | # Require the gems listed in Gemfile, including any gems
6 | # you've limited to :test, :development, or :production.
7 | Bundler.require(*Rails.groups)
8 |
9 | module Rails61
10 | class Application < Rails::Application
11 | # Initialize configuration defaults for originally generated Rails version.
12 | config.load_defaults 6.1
13 |
14 | # Configuration for the application, engines, and railties goes here.
15 | #
16 | # These settings can be overridden in specific environments using the files
17 | # in config/environments, which are processed later.
18 | #
19 | # config.time_zone = "Central Time (US & Canada)"
20 | # config.eager_load_paths << Rails.root.join("extras")
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/lib/spektr/checks/send.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class Send < Base
4 | def initialize(app, target)
5 | super
6 | @name = "Dangerous send"
7 | @type = "Dangerous send"
8 | @targets = ["Spektr::Targets::Base", "Spektr::Targets::Model", "Spektr::Targets::Controller", "Spektr::Targets::Routes", "Spektr::Targets::View"]
9 | end
10 |
11 | def run
12 | return unless super
13 | [:send, :try, :__send__, :public_send].each do |method|
14 | @target.find_calls(method).each do |call|
15 | argument = call.arguments.arguments.first
16 | if user_input?(argument)
17 | warn! @target, self, call.location, "User supplied value in send"
18 | end
19 | end
20 | end
21 | end
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/lib/spektr/warning.rb:
--------------------------------------------------------------------------------
1 | require 'digest'
2 |
3 | module Spektr
4 | class Warning
5 | attr_accessor :path, :full_path, :check, :location, :message, :confidence, :line
6 |
7 | def initialize(path, full_path, check, location, message, confidence = :high)
8 | @path = path
9 | @check = check
10 | @location = location
11 | @message = message
12 | @confidence = confidence
13 | @line = IO.readlines(full_path)[@location.start_line - 1].strip if full_path && @location && File.exist?(full_path)
14 | end
15 |
16 | def full_message
17 | if @location
18 | "#{message} at line #{@location.start_line} of #{@path}"
19 | else
20 | "#{message}"
21 | end
22 | end
23 |
24 | def fingerprint
25 | Digest::MD5.hexdigest("#{path}:#{line}:#{check.name}")
26 | end
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yaml:
--------------------------------------------------------------------------------
1 | # This workflow uses actions that are not certified by GitHub.
2 | # They are provided by a third-party and are governed by
3 | # separate terms of service, privacy policy, and support
4 | # documentation.
5 |
6 | name: CI
7 |
8 | on:
9 | push:
10 | branches: [ master ]
11 | pull_request:
12 | branches: [ master ]
13 |
14 | jobs:
15 | test:
16 |
17 | runs-on: ubuntu-latest
18 | strategy:
19 | fail-fast: false
20 | matrix:
21 | ruby: [3.2, 3.3, 3.4]
22 | steps:
23 | - uses: actions/checkout@v2
24 | - name: Set up Ruby
25 | uses: ruby/setup-ruby@v1
26 | with:
27 | bundler-cache: true
28 | ruby-version: ${{ matrix.ruby }}
29 | - name: Install dependencies
30 | run: bundle install
31 | - name: Run tests
32 | run: bundle exec rake
33 |
--------------------------------------------------------------------------------
/lib/spektr/checks/csrf_setting.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class CsrfSetting < Base
4 | def initialize(app, target)
5 | super
6 | @name = 'Cross-Site Request Forgery'
7 | @type = 'Cross-Site Request Forgery'
8 | @targets = ['Spektr::Targets::Controller']
9 | end
10 |
11 | def run
12 | return unless super
13 | return if @target.concern?
14 |
15 | target = @target
16 | return if @target.find_calls(:skip_forgery_protection).none?
17 |
18 | skip = @target.find_calls(:skip_forgery_protection).last
19 | return if skip && skip.arguments && skip.arguments.arguments.first.elements.map(&:key).map(&:unescaped).intersection(%w[only except]).any?
20 |
21 | warn! @target, self, nil, 'protect_from_forgery should be enabled'
22 | end
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/app/views/welcome/index.html.erb:
--------------------------------------------------------------------------------
1 | Welcome#index
2 | Find me in app/views/welcome/index.html.erb
3 | <%= content_tag :div, @welcome_message %>
4 | <%= content_tag :div, @welcome_message, @safe_attr => "foobar" %>
5 | <%= "/users/#{current_user.id}/messages.json".inspect.html_safe %>
6 | <%== @safe_attr %>
7 | <%= @post.title %>
8 | <%= @safe_attr.html_safe %>
9 | <%= raw Sensor::NAMES.map{|n| {val: n, text: t(n, scope: "sensors.categories")} } %>
10 |
11 | <%= content_tag :div, @welcome_message, @attr => "foobar" %>
12 | <%== params[:foobar] %>
13 | <%== @attr %>
14 | <%== params[:foobar].html_safe %>
15 | <%= params[:foobar].html_safe %>
16 | <%= @attr.html_safe %>
17 | <%= @post.title.html_safe %>
18 | <%== @post.title %>
19 | <%= raw cookies[:font] %>
20 | <% @posts.each do |post| %>
21 | <%= post.title.html_safe %>
22 | <% end %>
23 |
--------------------------------------------------------------------------------
/test/spektr/checks/basic_auth_timing_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class BasicAuthTimingTest < Minitest::Test
4 |
5 | def setup
6 | @code = <<-CODE
7 | class ApplicationController
8 | http_basic_authenticate_with name: "dhh", password: "secret", except: :index
9 | end
10 | CODE
11 | @app = Spektr::App.new(checks: [Spektr::Checks::BasicAuthTiming])
12 | @controller = Spektr::Targets::Controller.new("application_controller.rb", @code)
13 | @check = Spektr::Checks::BasicAuthTiming.new(@app, @controller)
14 | end
15 |
16 | def test_it_fails_with_no_rails_version
17 | @check.run
18 | assert_equal 1, @app.warnings.size
19 | end
20 |
21 | def test_it_does_not_fail_with_non_affected_version
22 | @app.rails_version = Gem::Version.new "6.0.1"
23 | @check.run
24 | assert_equal 0, @app.warnings.size
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/lib/spektr/checks/evaluation.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class Evaluation < Base
4 | def initialize(app, target)
5 | super
6 | @name = "Arbitrary code execution"
7 | @type = "Remote Code Execution"
8 | @targets = ["Spektr::Targets::Base", "Spektr::Targets::Model", "Spektr::Targets::Controller", "Spektr::Targets::Routes", "Spektr::Targets::View"]
9 | end
10 |
11 | def run
12 | return unless super
13 | [:eval, :instance_eval, :class_eval, :module_eval].each do |name|
14 | @target.find_calls(name).each do |call|
15 | call.arguments.arguments.each do |argument|
16 | if user_input?(argument)
17 | warn! @target, self, call.location, "User input in eval"
18 | end
19 | end
20 | end
21 | end
22 | end
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/lib/spektr/checks/basic_auth.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class BasicAuth < Base
4 |
5 | def initialize(app, target)
6 | super
7 | @name = "Basic Authentication"
8 | @type = "Password Plaintext Storage"
9 | @targets = ["Spektr::Targets::Controller"]
10 | end
11 |
12 | def run
13 | return unless super
14 | check_filter
15 | end
16 |
17 | def check_filter
18 | calls = @target.find_calls(:http_basic_authenticate_with)
19 | calls.each do |call|
20 | password = call.arguments.arguments.first.elements.find{|e| e.key.unescaped == "password" }
21 | if password && password.value.type == :string_node
22 | warn! @target, self, call.location, "Basic authentication password stored in source code"
23 | end
24 | end
25 | end
26 | end
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/lib/spektr/checks/file_disclosure.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class FileDisclosure < Base
4 |
5 | def initialize(app, target)
6 | super
7 | @name = "File existence disclosure"
8 | @type = "Information Disclosure"
9 | @targets = ["Spektr::Targets::Base"]
10 | end
11 |
12 | def run
13 | return unless super
14 | config = @app.production_config.find_calls(:serve_static_assets=).first
15 | if config && config.arguments.arguments.first.type == :true_node
16 | warn! "root", self, nil, "File existence disclosure vulnerability"
17 | end
18 | end
19 |
20 | def should_run?
21 | app_version_between?("2.0.0", "2.3.18") || app_version_between?("3.0.0", "3.2.20") || app_version_between?("4.0.0", "4.0.11") || app_version_between?("4.1.0", "4.1.7")
22 | end
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files.
2 | #
3 | # If you find yourself ignoring temporary files generated by your text editor
4 | # or operating system, you probably want to add a global ignore instead:
5 | # git config --global core.excludesfile '~/.gitignore_global'
6 |
7 | # Ignore bundler config.
8 | /.bundle
9 |
10 | # Ignore the default SQLite database.
11 | /db/*.sqlite3
12 | /db/*.sqlite3-*
13 |
14 | # Ignore all logfiles and tempfiles.
15 | /log/*
16 | /tmp/*
17 | !/log/.keep
18 | !/tmp/.keep
19 |
20 | # Ignore pidfiles, but keep the directory.
21 | /tmp/pids/*
22 | !/tmp/pids/
23 | !/tmp/pids/.keep
24 |
25 | # Ignore uploaded files in development.
26 | /storage/*
27 | !/storage/.keep
28 |
29 | /public/assets
30 | .byebug_history
31 |
32 | # Ignore master key for decrypting credentials and more.
33 | /config/master.key
34 |
--------------------------------------------------------------------------------
/lib/spektr/checks/dynamic_finders.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class DynamicFinders < Base
4 |
5 | def initialize(app, target)
6 | super
7 | @name = "SQL Injection by unsafe usage of find_by_*"
8 | @type = "SQL Injection"
9 | @targets = ["Spektr::Targets::Base", "Spektr::Targets::Controller", "Spektr::Targets::View"]
10 | end
11 |
12 | def run
13 | return unless super
14 | if app_version_between?("2.0.0", "4.1.99") && @app.has_gem?("mysql")
15 | @target.find_calls(/^find_by_/).each do |call|
16 | call.arguments.arguments.each do |argument|
17 | if user_input?(argument)
18 | warn! @target, self, call.location, "MySQL integer conversion may cause 0 to match any string"
19 | end
20 | end
21 | end
22 | end
23 | end
24 | end
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/lib/spektr/checks/json_encoding.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class JsonEncoding < Base
4 | def initialize(app, target)
5 | super
6 | @name = "XSS by missing JSON encoding"
7 | @type = "Cross-Site Scripting"
8 | @targets = ["Spektr::Targets::Base", "Spektr::Targets::Controller", "Spektr::Targets::Routes", "Spektr::Targets::View"]
9 | end
10 |
11 | def run
12 | # TODO: write a test for this
13 | return unless super
14 | if app_version_between?("4.1.0", "4.1.10") || app_version_between?("4.2.0", "4.2.1")
15 | if calls = @target.find_calls(:to_json).any? || calls = @target.find_calls(:encode).any?
16 | warn! @target, self, calls.first.location, "Cross-Site Scripting CVE_2015_3226"
17 | else
18 | warn! "root", self, nil, "Cross-Site Scripting CVE_2015_3226"
19 | end
20 | end
21 | end
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/lib/spektr/checks/create_with.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class CreateWith < Base
4 | def initialize(app, target)
5 | super
6 | @name = "Strong parameter bypass (CVE-2014-3514)"
7 | @type = "Input validation"
8 | @targets = ["Spektr::Targets::Base", "Spektr::Targets::Controller", "Spektr::Targets::Routes", "Spektr::Targets::View"]
9 | end
10 |
11 | def run
12 | return unless super
13 | if app_version_between?("4.0.0", "4.0.8") || app_version_between?("4.1.0", "4.1.5")
14 | calls = @target.find_calls(:create_with)
15 | calls.each do |call|
16 | call.arguments.arguments.each do |argument|
17 | if user_input?(argument)
18 | next if argument.name == :permit
19 | warn! @target, self, call.location, "create_with is vulnerable to strong params bypass"
20 | end
21 | end
22 | end
23 | end
24 | end
25 | end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config/locales/en.yml:
--------------------------------------------------------------------------------
1 | # Files in the config/locales directory are used for internationalization
2 | # and are automatically loaded by Rails. If you want to use locales other
3 | # than English, add the necessary files in this directory.
4 | #
5 | # To use the locales, use `I18n.t`:
6 | #
7 | # I18n.t 'hello'
8 | #
9 | # In views, this is aliased to just `t`:
10 | #
11 | # <%= t('hello') %>
12 | #
13 | # To use a different locale, set it with `I18n.locale`:
14 | #
15 | # I18n.locale = :es
16 | #
17 | # This would use the information in config/locales/es.yml.
18 | #
19 | # The following keys must be escaped otherwise they will not be retrieved by
20 | # the default I18n backend:
21 | #
22 | # true, false, on, off, yes, no
23 | #
24 | # Instead, surround them with single quotes.
25 | #
26 | # en:
27 | # 'true': 'foo'
28 | #
29 | # To learn more, please read the Rails Internationalization guide
30 | # available at https://guides.rubyonrails.org/i18n.html.
31 |
32 | en:
33 | hello: "Hello world"
34 |
--------------------------------------------------------------------------------
/test/spektr/checks/dynamic_finders_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class DynamicFindersTest < Minitest::Test
4 | def setup
5 | @code = <<-CODE
6 | class ApplicationController
7 | def index
8 | Post.find_by_title(params[:q])
9 | Post.find_by_title("test")
10 | end
11 | end
12 | CODE
13 | @app = Spektr::App.new(checks: [Spektr::Checks::DynamicFinders])
14 | @app.gem_specs = [Bundler::LazySpecification.new("mysql", 1, "linux")]
15 | @app.rails_version = Gem::Version.new "5.0.1"
16 | @controller = Spektr::Targets::Controller.new("application_controller.rb", @code)
17 | @check = Spektr::Checks::DynamicFinders.new(@app, @controller)
18 | end
19 |
20 | def test_it_does_not_fail_with_rails_5
21 | @check.run
22 | assert_equal 0, @app.warnings.size
23 | end
24 |
25 | def test_it_fails_with_rails_4
26 | @app.rails_version = Gem::Version.new "4.0.1"
27 | @check.run
28 | assert_equal 1, @app.warnings.size
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/lib/spektr/checks/detailed_exceptions.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class DetailedExceptions < Base
4 |
5 | def name
6 |
7 | end
8 |
9 | def initialize(app, target)
10 | super
11 | @name = "Information Disclosure"
12 | @type = "Information Disclosure"
13 | @targets = ["Spektr::Targets::Base", "Spektr::Targets::Controller"]
14 | end
15 |
16 | def run
17 | return unless super
18 | call = @target.find_calls(:consider_all_requests_local=).last
19 | if call && call.arguments.arguments.first.type == :true_node
20 | warn! @target, self, call.location, "Detailed exceptions are enabled in production"
21 | end
22 | # TODO: make this better, by verifying that the method body is not empty, etc
23 | if call = @target.find_method(:show_detailed_exceptions?)
24 | warn! @target, self, call.location, "Detailed exceptions may be enabled in #{@target.name}"
25 | end
26 | end
27 | end
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/lib/spektr/checks/digest_dos.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class DigestDos < Base
4 | def initialize(app, target)
5 | super
6 | @name = "DoS in digest authentication(CVE-2012-3424)"
7 | @type = "Denial of Service"
8 | @targets = ["Spektr::Targets::Base", "Spektr::Targets::Controller"]
9 | end
10 |
11 | def run
12 | return unless super
13 | return unless should_run?
14 | calls = @target.find_calls(:authenticate_or_request_with_http_digest)
15 | calls.concat(@target.find_calls(:authenticate_with_http_digest))
16 | if calls.any?
17 | warn! @target, self, calls.first.location, "Vulnerability in digest authentication CVE-2012-3424"
18 | else
19 | warn! "root", self, nil, "Vulnerability in digest authentication CVE-2012-3424"
20 | end
21 | end
22 |
23 | def should_run?
24 | app_version_between?("3.0.0", "3.0.15") || app_version_between?("3.1.0", "3.1.6") || app_version_between?("3.2.0", "3.2.5")
25 | end
26 | end
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/test/spektr/checks/detailed_exceptions_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class DetailedExceptionsTest < Minitest::Test
4 |
5 | def test_it_fails_with_insecure_config
6 | code = <<-CODE
7 | Rails.application.configure do
8 | config.consider_all_requests_local = true
9 | end
10 | CODE
11 | app = Spektr::App.new(checks: [Spektr::Checks::DetailedExceptions])
12 | config = Spektr::Targets::Base.new("production.rb", code)
13 | check = Spektr::Checks::DetailedExceptions.new(app, config)
14 | check.run
15 | assert_equal 1, app.warnings.size
16 | end
17 |
18 | def test_it_fails_with_insecure_controller
19 | code = <<-CODE
20 | class ApplicationController
21 | def show_detailed_exceptions?
22 | admin?
23 | end
24 | end
25 | CODE
26 | app = Spektr::App.new(checks: [Spektr::Checks::DetailedExceptions])
27 | config = Spektr::Targets::Controller.new("production.rb", code)
28 | check = Spektr::Checks::DetailedExceptions.new(app, config)
29 | check.run
30 | assert_equal 1, app.warnings.size
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/lib/spektr/checks/filter_skipping.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class FilterSkipping < Base
4 | def initialize(app, target)
5 | super
6 | @name = "Default routes filter skipping"
7 | @type = "Default Routes"
8 | @targets = ["Spektr::Targets::Routes"]
9 | end
10 |
11 | def run
12 | # TODO: write a test for this
13 | return unless super
14 | calls = %w{ match get post put delete }.inject([]) do |memo, method|
15 | memo.concat @target.find_calls(method.to_sym)
16 | memo
17 | end
18 | calls.each do |call|
19 | arguments = call.arguments.arguments
20 | if arguments && (arguments.first.name.include?(":action") or arguments.first.name.include?("*action"))
21 | warn! @target, self, call.location, "CVE-2011-2929 Rails versions before 3.0.10 have a vulnerability which allows filters to be bypassed"
22 | end
23 | end
24 | end
25 |
26 | def should_run?
27 | app_version_between?("3.0.0", "3.0.9")
28 | end
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/test/spektr/checks/json_entity_escape_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class JsonEntityEscapeTest < Minitest::Test
4 |
5 | def test_it_fails_with_insecure_config
6 | code = <<-CODE
7 | Rails.application.configure do
8 | config.active_support.escape_html_entities_in_json = false
9 | end
10 | CODE
11 | app = Spektr::App.new(checks: [Spektr::Checks::JsonEntityEscape])
12 | config = Spektr::Targets::Base.new("production.rb", code)
13 | app.production_config = config
14 | check = Spektr::Checks::JsonEntityEscape.new(app, config)
15 | check.run
16 | assert_equal 1, app.warnings.size
17 | end
18 |
19 | def test_it_fails_when_disabled_in_initializer
20 | code = <<-CODE
21 | # frozen_string_literal: true
22 | ActiveSupport::JSON::Encoding::escape_html_entities_in_json = false
23 | CODE
24 | app = Spektr::App.new(checks: [Spektr::Checks::JsonEntityEscape])
25 | config = Spektr::Targets::Base.new("html_entities.rb", code)
26 | check = Spektr::Checks::JsonEntityEscape.new(app, config)
27 | check.run
28 | assert_equal 1, app.warnings.size
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/lib/spektr/targets/view.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | module Targets
3 | class View < Base
4 | TEMPLATE_EXTENSIONS = /.*\.(erb|rhtml|haml|slim|herb)$/
5 | attr_accessor :view_path
6 |
7 | def initialize(path, content)
8 | super
9 | @calls = []
10 | Spektr.logger.debug "loading #{path}"
11 | @view_path = nil
12 | @path = path
13 | if match_data = path.match(%r{views/(.+?)\.})
14 | @view_path = match_data[1]
15 | end
16 | @ast = Prism.parse(source(content))
17 | @ast.value.accept(self)
18 | @name = @view_path
19 | end
20 |
21 | def source(content)
22 | type = @path.match(TEMPLATE_EXTENSIONS)[1].to_sym
23 | case type
24 | when :erb, :rhtml, :herb
25 | Erubi.new(content, trim_mode: '-').src
26 | # Herb.extract_ruby(content)
27 | when :haml
28 | Haml::Engine.new(content).precompiled
29 | when :slim
30 | erb = Slim::ERBConverter.new.call(content)
31 | Erubi.new(erb, trim_mode: '-').src
32 | end
33 | end
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/lib/spektr/targets/routes.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | module Targets
3 | class Routes < Base
4 | attr_accessor :routes
5 |
6 | def initialize(path, content)
7 | super
8 | end
9 |
10 | def find_actions
11 | @actions = find_methods(ast: @ast, type: :public ).map do |ast|
12 | Action.new(ast, self)
13 | end
14 | end
15 |
16 | class Action
17 | attr_accessor :controller, :template
18 | def initialize(ast, controller)
19 | super(ast)
20 | @template = File.join(controller.name.delete_suffix("Controller").underscore, name.to_s)
21 | @body.each do |exp|
22 | if exp.send?
23 | if exp.name == :render
24 | if exp.arguments.first.type == :sym
25 | @template = File.join(controller.name.delete_suffix("Controller").underscore, exp.arguments.first.name.to_s)
26 | elsif
27 | if exp.arguments.first.type == :str
28 | @template = exp.arguments.first.name
29 | end
30 | end
31 | end
32 | end
33 | end
34 | end
35 | end
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/bin/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require "fileutils"
3 |
4 | # path to your application root.
5 | APP_ROOT = File.expand_path('..', __dir__)
6 |
7 | def system!(*args)
8 | system(*args) || abort("\n== Command #{args} failed ==")
9 | end
10 |
11 | FileUtils.chdir APP_ROOT do
12 | # This script is a way to set up or update your development environment automatically.
13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome.
14 | # Add necessary setup steps to this file.
15 |
16 | puts '== Installing dependencies =='
17 | system! 'gem install bundler --conservative'
18 | system('bundle check') || system!('bundle install')
19 |
20 | # Install JavaScript dependencies
21 | system! 'bin/yarn'
22 |
23 | # puts "\n== Copying sample files =="
24 | # unless File.exist?('config/database.yml')
25 | # FileUtils.cp 'config/database.yml.sample', 'config/database.yml'
26 | # end
27 |
28 | puts "\n== Preparing database =="
29 | system! 'bin/rails db:prepare'
30 |
31 | puts "\n== Removing old logs and tempfiles =="
32 | system! 'bin/rails log:clear tmp:clear'
33 |
34 | puts "\n== Restarting application server =="
35 | system! 'bin/rails restart'
36 | end
37 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config/storage.yml:
--------------------------------------------------------------------------------
1 | test:
2 | service: Disk
3 | root: <%= Rails.root.join("tmp/storage") %>
4 |
5 | local:
6 | service: Disk
7 | root: <%= Rails.root.join("storage") %>
8 |
9 | # Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
10 | # amazon:
11 | # service: S3
12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
14 | # region: us-east-1
15 | # bucket: your_own_bucket
16 |
17 | # Remember not to checkin your GCS keyfile to a repository
18 | # google:
19 | # service: GCS
20 | # project: your_project
21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %>
22 | # bucket: your_own_bucket
23 |
24 | # Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
25 | # microsoft:
26 | # service: AzureStorage
27 | # storage_account_name: your_account_name
28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
29 | # container: your_container_name
30 |
31 | # mirror:
32 | # service: Mirror
33 | # primary: local
34 | # mirrors: [ amazon, google, microsoft ]
35 |
--------------------------------------------------------------------------------
/test/spektr/checks/json_parsing_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class JsonParsingTest < Minitest::Test
4 |
5 | def test_fails_with_json_gem_backend
6 | code = <<-CODE
7 | ActiveSupport::JSON.backend = JSONGem
8 | CODE
9 | app = Spektr::App.new(checks: [Spektr::Checks::JsonParsing])
10 | config = Spektr::Targets::Base.new("production.rb", code)
11 | check = Spektr::Checks::JsonParsing.new(app, config)
12 | check.run
13 | assert_equal 1, app.warnings.size
14 |
15 | end
16 |
17 | def test_it_fails_with_yajl_gem
18 | code = ""
19 | app = Spektr::App.new(checks: [Spektr::Checks::JsonParsing])
20 | app.gem_specs = [Bundler::LazySpecification.new("yajl", 1, "linux")]
21 | config = Spektr::Targets::Base.new("production.rb", code)
22 | check = Spektr::Checks::JsonParsing.new(app, config)
23 | check.run
24 | assert_equal 1, app.warnings.size
25 | end
26 |
27 | def test_it_fails_with_json_and_json_pure
28 | app = Spektr::App.new(checks: [Spektr::Checks::JsonParsing])
29 | app.gem_specs = [Bundler::LazySpecification.new("json", "1.7.2", "linux")]
30 | config = Spektr::Targets::Base.new("production.rb", "")
31 | check = Spektr::Checks::JsonParsing.new(app, config)
32 | check.run
33 | assert_equal 1, app.warnings.size
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/test/spektr/checks/default_routes_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class DefaultRoutesTest < Minitest::Test
4 |
5 | def setup
6 | @app = Spektr::App.new(root: RAILS_6_1_ROOT, checks: [Spektr::Checks::DefaultRoutes])
7 | end
8 |
9 | def test_match_all_actions_with_rails_3
10 | @app.rails_version = Gem::Version.new "3.0.1"
11 | code = <<-CODE
12 | Rails3::Application.routes.draw do
13 | match ':controller(/:action(/:id(.:format)))'
14 | match ':controller/:action'
15 | match 'posts/:action', controller: 'posts'
16 | match 'posts/*action', controller: 'posts'
17 | end
18 | CODE
19 | routes = Spektr::Targets::Routes.new("routes.rb", code)
20 | check = Spektr::Checks::DefaultRoutes.new(@app, routes)
21 | check.run
22 | assert_equal 5, @app.warnings.size
23 | end
24 |
25 | def test_verb_all_actions_with_rails_3
26 | @app.rails_version = Gem::Version.new "3.0.1"
27 | code = <<-CODE
28 | Rails3::Application.routes.draw do
29 | get ":controller/:action"
30 | get "/posts/:action"
31 | post "/posts/:action"
32 | end
33 | CODE
34 | routes = Spektr::Targets::Routes.new("routes.rb", code)
35 | check = Spektr::Checks::DefaultRoutes.new(@app, routes)
36 | check.run
37 | assert_equal 4, @app.warnings.size
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Spektr Public Use Licence
2 |
3 | Copyright (c) 2022 Spektr Security LLC
4 |
5 | Commercial Use of the Software for commercial purposes requires a commercial licence but any non-commercial use is
6 | allowed free of charge.
7 |
8 | Commercial use includes offering the software as part of a Software as a Service, or part of a distributed software
9 | where it is used as part of that software.
10 |
11 | Non-commercial use includes anything else, for instance when lincecee scans his own codebase for vulnerabilities,
12 | or as part of a security audit engagement the licencee scans the target's source code for vulnerabilities.
13 |
14 | Licencee is also permitted to embed the scanner into his own closed source application as long as they don't break
15 | the non-commercial use requirements of the software.
16 |
17 |
18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24 | THE SOFTWARE.
25 |
26 | Software: Spektr Scanner
27 | Licensor: Spektr Security LLC
28 |
--------------------------------------------------------------------------------
/lib/spektr/checks/json_entity_escape.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class JsonEntityEscape < Base
4 |
5 | def initialize(app, target)
6 | super
7 | @name = "HTML escaping is disabled for JSON output"
8 | @type = "Cross-Site Scripting"
9 | @targets = ["Spektr::Targets::Config", "Spektr::Targets::Base"]
10 | end
11 |
12 | def run
13 | return unless super
14 | if @app.production_config
15 | config = @app.production_config.find_calls(:escape_html_entities_in_json=).first
16 | end
17 | if config and full_receiver(config) == "config.active_support" && config.arguments.arguments.first.type == :false_node
18 | warn! @app.production_config.path, self, nil, "HTML entities in JSON are not escaped by default"
19 | end
20 |
21 | if @target.find_calls(:escape_html_entities_in_json=, 'ActiveSupport'.to_sym).any?
22 | warn! @target, self, calls.first.location, "HTML entities in JSON are not escaped by default"
23 | end
24 | calls = @target.find_calls(:escape_html_entities_in_json=, 'JSON::Encoding'.to_sym)
25 | calls.each do |call|
26 | if full_receiver(call) == 'ActiveSupport.JSON.Encoding'
27 | warn! @target, self, calls.first.location, "HTML entities in JSON are not escaped by default"
28 | end
29 | end
30 | end
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/lib/spektr/checks/file_access.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class FileAccess < Base
4 |
5 | def name
6 | "File access"
7 | end
8 |
9 | def initialize(app, target)
10 | super
11 | @name = "File access"
12 | @type = "Information Disclosure"
13 | @targets = ["Spektr::Targets::Base", "Spektr::Targets::Controller", "Spektr::Targets::Routes", "Spektr::Targets::View"]
14 | end
15 |
16 | def run
17 | return unless super
18 | targets = ["Dir", "File", "IO", "Kernel", "Net::FTP", "Net::HTTP", "PStore", "Pathname", "Shell"]
19 | methods = [:[], :chdir, :chroot, :delete, :entries, :foreach, :glob, :install, :lchmod, :lchown, :link, :load, :load_file, :makedirs, :move, :new, :open, :read, :readlines, :rename, :rmdir, :safe_unlink, :symlink, :syscopy, :sysopen, :truncate, :unlink]
20 | targets.each do |target|
21 | methods.each do |method|
22 | check_calls_for_user_input(@target.find_calls(method, target.to_sym))
23 | end
24 | end
25 | end
26 |
27 | def check_calls_for_user_input(calls)
28 | calls.each do |call|
29 | call.arguments.arguments.each do |argument|
30 | if user_input?(argument)
31 | warn! @target, self, call.location, "#{argument.name} is used for a filename, which enables an attacker to access arbitrary files."
32 | end
33 | end
34 | end
35 | end
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/lib/spektr/cli.rb:
--------------------------------------------------------------------------------
1 | require 'tty/option'
2 | require 'json'
3 | module Spektr
4 | class Cli
5 | include TTY::Option
6 | usage do
7 | program 'Spektr'
8 | command 'scan'
9 | desc 'Find vulnerabilities in ruby code'
10 | end
11 |
12 | argument :root do
13 | optional
14 | desc 'Path to application root'
15 | end
16 |
17 | flag :output_format do
18 | long '--output_format string'
19 | desc 'output format terminal or json'
20 | default 'terminal'
21 | end
22 |
23 | flag :check do
24 | long '--check string'
25 | desc 'run this single check'
26 | end
27 |
28 | flag :ignore do
29 | long '--ignore string'
30 | desc 'comma separated list of fingerprints to ignore'
31 | end
32 |
33 | flag :debug do
34 | long '--debug'
35 | short '-d'
36 | desc 'output debug logs to STDOUT'
37 | end
38 |
39 | flag :help do
40 | short '-h'
41 | long '--help'
42 | desc 'Print usage'
43 | end
44 |
45 | def scan
46 | if params[:help]
47 | print help
48 | exit
49 | else
50 | ignore = params[:ignore] ? params[:ignore].split(',') : []
51 | report = Spektr.run(params[:root], params[:output_format], params[:debug], params[:check], ignore)
52 | case params[:output_format]
53 | when 'json'
54 | puts JSON.pretty_generate report
55 | exit 1 if report[:advisories].any?
56 | end
57 | end
58 | end
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/lib/spektr/checks/mass_assignment.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class MassAssignment < Base
4 |
5 | # TODO: Make this better
6 | def initialize(app, target)
7 | super
8 | @name = "Mass Assignment"
9 | @type = "Input Validation"
10 | @targets = ["Spektr::Targets::Controller"]
11 | end
12 |
13 | def run
14 | return unless super
15 | model_names = @app.models.collect(&:name)
16 | calls = []
17 | model_names.each do |receiver|
18 | [:new, :build, :create].each do |method|
19 | calls.concat @target.find_calls(method, receiver.to_sym)
20 | end
21 | end
22 | calls.each do |call|
23 | argument = call.arguments&.arguments&.first
24 | next if argument.nil?
25 | ::Spektr.logger.debug "Mass assignment check at #{call.location.start_line}"
26 | if user_input?(argument)
27 | # we check for permit! separately
28 | next if argument.name == :permit!
29 | # check for permit with arguments
30 | next if argument.name == :permit && argument.arguments
31 | warn! @target, self, call.location, "Mass assignment"
32 | end
33 | end
34 | @target.find_calls(:permit!).each do |call|
35 | unless call.arguments
36 | warn! @target, self, call.location, "permit! allows any keys, use it with caution!", :medium
37 | end
38 | end
39 | end
40 | end
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config/initializers/content_security_policy.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Define an application-wide content security policy
4 | # For further information see the following documentation
5 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
6 |
7 | # Rails.application.config.content_security_policy do |policy|
8 | # policy.default_src :self, :https
9 | # policy.font_src :self, :https, :data
10 | # policy.img_src :self, :https, :data
11 | # policy.object_src :none
12 | # policy.script_src :self, :https
13 | # policy.style_src :self, :https
14 | # # If you are using webpack-dev-server then specify webpack-dev-server host
15 | # policy.connect_src :self, :https, "http://localhost:3035", "ws://localhost:3035" if Rails.env.development?
16 |
17 | # # Specify URI for violation reports
18 | # # policy.report_uri "/csp-violation-report-endpoint"
19 | # end
20 |
21 | # If you are using UJS then enable automatic nonce generation
22 | # Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) }
23 |
24 | # Set the nonce only to specific directives
25 | # Rails.application.config.content_security_policy_nonce_directives = %w(script-src)
26 |
27 | # Report CSP violations to a specified URI
28 | # For further information see the following documentation:
29 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only
30 | # Rails.application.config.content_security_policy_report_only = true
31 |
--------------------------------------------------------------------------------
/test/spektr/checks/create_with_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class CreateWithTest < Minitest::Test
4 | def test_it_fails_with_affected_versions
5 | code = <<-CODE
6 | user.blog_posts.create_with(params[:blog_post]).create
7 | CODE
8 | app = Spektr::App.new(checks: [Spektr::Checks::CreateWith])
9 | app.rails_version = Gem::Version.new "4.0.0"
10 | initializer = Spektr::Targets::Base.new("posts_controller.rb", code)
11 | check = Spektr::Checks::CreateWith.new(app, initializer)
12 | check.run
13 | assert_equal 1, app.warnings.size
14 | end
15 |
16 | def test_it_does_not_fail_with_non_user_input
17 | code = <<-CODE
18 | user.blog_posts.create_with({title: "test"}).create
19 | CODE
20 | app = Spektr::App.new(checks: [Spektr::Checks::CreateWith])
21 | app.rails_version = Gem::Version.new "4.0.0"
22 | initializer = Spektr::Targets::Base.new("posts_controller.rb", code)
23 | check = Spektr::Checks::CreateWith.new(app, initializer)
24 | check.run
25 | assert_equal 0, app.warnings.size
26 | end
27 |
28 | def test_it_does_not_fail_with_permitted_params
29 | code = <<-CODE
30 | user.blog_posts.create_with(params[:blog_post].permit(:title)).create
31 | CODE
32 | app = Spektr::App.new(checks: [Spektr::Checks::CreateWith])
33 | app.rails_version = Gem::Version.new "4.0.0"
34 | initializer = Spektr::Targets::Base.new("posts_controller.rb", code)
35 | check = Spektr::Checks::CreateWith.new(app, initializer)
36 | check.run
37 | assert_equal 0, app.warnings.size
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/lib/spektr/checks/sqli.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class Sqli < Base
4 | def initialize(app, target)
5 | super
6 | @name = "SQL Injection"
7 | @name = "SQL Injection"
8 | @targets = ["Spektr::Targets::Base", "Spektr::Targets::Controller", "Spektr::Targets::Model"]
9 | end
10 |
11 | def run
12 | return unless super
13 |
14 | [
15 | :average, :count, :maximum, :minimum, :sum, :exists?,
16 | :find_by, :find_by!, :find_or_create_by, :find_or_create_by!,
17 | :find_or_initialize_by, :from, :group, :having, :join, :lock,
18 | :where, :not, :select, :rewhere, :reselect, :update_all, :find_by_sql
19 |
20 | ].each do |m|
21 | @target.find_calls(m).each do |call|
22 | check_argument(call.arguments&.arguments&.first, m, call)
23 | end
24 | end
25 | [:calculate].each do |m|
26 | @target.find_calls(m).each do |call|
27 | check_argument(call.arguments.arguments[1], m, call)
28 | end
29 | end
30 |
31 | [:delete_by, :destroy_by].each do |m|
32 | @target.find_calls(m).each do |call|
33 | if call.arguments.arguments.first
34 | check_argument(call.arguments.arguments.first, m, call)
35 | end
36 | end
37 | end
38 | end
39 |
40 | def check_argument(argument, method, call)
41 | return if argument.nil?
42 | if user_input?(argument)
43 | warn! @target, self, call.location, "Possible SQL Injection at #{method}"
44 | end
45 | end
46 | end
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/lib/spektr/checks/link_to_href.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class LinkToHref < Base
4 | def initialize(app, target)
5 | super
6 | @name = "XSS in href param of link_to"
7 | @type = "Cross-Site Scripting"
8 | @targets = ["Spektr::Targets::Base", "Spektr::Targets::Controller", "Spektr::Targets::View"]
9 | end
10 |
11 | # TODO: check for user supplied model attributes too
12 | def run
13 | return unless super
14 | block_locations = []
15 | @target.find_calls_with_block(:link_to).each do |call|
16 | block_locations << call.location
17 | next unless call.arguments.arguments.first
18 | ::Spektr.logger.debug "#{@target.path} #{call.location.start_line} #{call.arguments.arguments.first.inspect}"
19 | if user_input? call.arguments.arguments.first
20 | warn! @target, self, call.location, "Cross-Site Scripting: Unsafe user supplied value in link_to"
21 | end
22 | end
23 |
24 | @target.find_calls(:link_to).each do |call|
25 | next if block_locations.include? call.location
26 | next unless call.arguments
27 | ::Spektr.logger.debug "#{@target.path} #{call.location.start_line} #{call.arguments.arguments[1].inspect}"
28 | next unless call.arguments.arguments[1]
29 | next if call.arguments.arguments[1].name =~ /_url$|_path$/
30 | if user_input? call.arguments.arguments[1]
31 | warn! @target, self, call.location, "Cross-Site Scripting: Unsafe user supplied value in link_to"
32 | end
33 | end
34 | end
35 | end
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/test/spektr/checks/mass_assignment_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class MassAssignmentTest < Minitest::Test
4 | def setup
5 | model = <<-CODE
6 | class Post
7 | end
8 | CODE
9 | @app = Spektr::App.new(checks: [Spektr::Checks::MassAssignment])
10 | model = Spektr::Targets::Model.new("post.rb", model)
11 | @app.models << model
12 | end
13 |
14 | def test_it_fails_with_params_assignment
15 | code = <<-CODE
16 | class BlogController
17 | def create
18 | post = Post.new(params[:post])
19 | end
20 | end
21 | CODE
22 | controller = Spektr::Targets::Controller.new("blog_controller.rb", code)
23 | check = Spektr::Checks::MassAssignment.new(@app, controller)
24 | check.run
25 | assert_equal 1, @app.warnings.size
26 | end
27 |
28 | def test_it_doesnt_fail_with_permit
29 | code = <<-CODE
30 | class BlogController
31 | def create
32 | post = Post.new(params[:post].permit(:title, :body))
33 | end
34 | end
35 | CODE
36 | controller = Spektr::Targets::Controller.new("blog_controller.rb", code)
37 | check = Spektr::Checks::MassAssignment.new(@app, controller)
38 | check.run
39 | assert_equal 0, @app.warnings.size
40 | end
41 |
42 | def test_it_fails_with_permit_bang
43 | code = <<-CODE
44 | class BlogController
45 | def create
46 | post = Post.new(params[:post].permit!)
47 | end
48 | end
49 | CODE
50 | controller = Spektr::Targets::Controller.new("blog_controller.rb", code)
51 | check = Spektr::Checks::MassAssignment.new(@app, controller)
52 | check.run
53 | assert_equal 1, @app.warnings.size
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/lib/spektr/checks/json_parsing.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class JsonParsing < Base
4 | def initialize(app, target)
5 | super
6 | @name = "JSON parsing vulnerability"
7 | @type = "Remote Code Execution"
8 | @targets = ["Spektr::Targets::Base"]
9 | end
10 |
11 | def run
12 | return unless super
13 | check_cve_2013_0333
14 | check_cve_2013_0269
15 | end
16 |
17 | def check_cve_2013_0333
18 | return unless app_version_between?("0.0.0", "2.3.15") || app_version_between?("3.0.0", "3.0.19")
19 | if @app.has_gem?("yajl")
20 | warn! "root", self, nil, "Remote Code Execution CVE_2013_0333"
21 | end
22 | uses_json_gem?
23 | end
24 |
25 | def uses_json_gem?
26 | @target.find_calls(:backend=).each do |call|
27 | if full_receiver(call) == "ActiveSupport.JSON" && call.arguments.arguments.first&.name == :JSONGem
28 | warn! @target, self, call.location, "Remote Code Execution CVE_2013_0333"
29 | end
30 | end
31 | end
32 |
33 | def check_cve_2013_0269
34 | ["json", "json_pure"].each do |gem_name|
35 | if g = @app.gem_specs&.find { |g| g.name == gem_name }
36 | if version_between?("1.7.0", "1.7.6", g.version)
37 | warn! "Gemfile", self, nil, "Unsafe Object Creation Vulnerability in the #{g.name} gem"
38 | end
39 | if version_between?("0", "1.5.4", g.version) || version_between?("1.6.0", "1.6.7", g.version)
40 | warn! "Gemfile", self, nil, "Unsafe Object Creation Vulnerability in the #{g.name} gem"
41 | end
42 | end
43 | end
44 | end
45 | end
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/lib/spektr/checks/default_routes.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class DefaultRoutes < Base
4 | def initialize(app, target)
5 | super
6 | @name = "Dangerous default routes"
7 | @targets = ["Spektr::Targets::Routes"]
8 | end
9 |
10 | def run
11 | return unless super
12 | @type = "Remote Code Execution"
13 | check_for_cve_2014_0130
14 | @type = "Default routes"
15 | check_for_default_routes
16 | end
17 |
18 | def check_for_default_routes
19 | if app_version_between?(3, 4)
20 | calls = %w{ match get post put delete }.inject([]) do |memo, method|
21 | memo.concat @target.find_calls(method.to_sym)
22 | memo
23 | end
24 | calls.each do |call|
25 | argument_value = call.arguments.arguments.first.unescaped
26 | if argument_value == ":controller(/:action(/:id(.:format)))" or (argument_value.include?(":controller") && (argument_value.include?(":action") or argument_value.include?("*action")) )
27 | warn! @target, self, call.location, "All public methods in controllers are available as actions"
28 | end
29 |
30 | if argument_value.include?(":action") or argument_value.include?("*action")
31 | warn! @target, self, call.location, "All public methods in controllers are available as actions"
32 | end
33 | end
34 | end
35 | end
36 |
37 | def check_for_cve_2014_0130
38 | if app_version_between?("2.0.0", "2.3.18") || app_version_between?("3.0.0", "3.2.17") || app_version_between?("4.0.0", "4.0.4") || app_version_between?("4.1.0", "4.1.0")
39 | warn! @target, self, nil, "#{@app.rails_version} with globbing routes is vulnerable to directory traversal and remote code execution."
40 | end
41 | end
42 | end
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/lib/spektr/checks/deserialize.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class Deserialize < Base
4 |
5 | def initialize(app, target)
6 | super
7 | @name = "Unsafe object deserialization"
8 | @type = "Insecure Deserialization"
9 | @targets = ["Spektr::Targets::Base", "Spektr::Targets::Controller", "Spektr::Targets::Routes", "Spektr::Targets::View"]
10 | end
11 |
12 | def run
13 | return unless super
14 | check_csv
15 | check_yaml
16 | check_marshal
17 | check_oj
18 | end
19 |
20 | def check_csv
21 | check_method(:load, :CSV)
22 | end
23 |
24 | # TODO: handle safe yaml
25 | def check_yaml
26 | [:load_documents, :load_stream, :parse_documents, :parse_stream].each do |method|
27 | check_method(method, :YAML)
28 | end
29 | end
30 |
31 | def check_marshal
32 | [:load, :restore].each do |method|
33 | check_method(method, :Marshal)
34 | end
35 | end
36 |
37 | def check_oj
38 | check_method(:object_load, :Oj)
39 | safe_default = false
40 | safe_default = true if @target.find_calls(:mimic_JSON, :Oj).any?
41 | call = @target.find_calls(:default_options=, :Oj).last
42 | safe_default = true if call && call.arguments.arguments.first.elements.find{|e| e.key.unescaped == "mode" }.value.unescaped != "object"
43 | unless safe_default
44 | check_method(:load, :Oj)
45 | end
46 | end
47 |
48 | def check_method(method, receiver)
49 | calls = @target.find_calls(method, receiver)
50 | calls.each do |call|
51 | if user_input?(call.arguments.arguments.first)
52 | warn! @target, self, call.location, "#{receiver}.#{method} is called with user supplied value"
53 | end
54 | end
55 | end
56 | end
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/test/spektr/checks/link_to_href_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class LinkToHrefTest < Minitest::Test
4 | def test_it_fails_with_a_user_supplied_value_with_block
5 | code = <<-CODE
6 | <%=
7 | link_to params[:blog] do
8 | "hello"
9 | end
10 | %>
11 | CODE
12 | app = Spektr::App.new(checks: [Spektr::Checks::LinkToHref])
13 | view = Spektr::Targets::View.new("index.html.erb", code)
14 | check = Spektr::Checks::LinkToHref.new(app, view)
15 | check.run
16 | assert_equal 1, app.warnings.size
17 | end
18 |
19 | def test_it_fails_with_a_user_supplied_value
20 | code = <<-CODE
21 | <%= link_to "Hello", params[:blog] %>
22 | <%= link_to "Hello".html_safe, params[:blog] %>
23 | link_to "\#{inline_svg_tag('email.svg', aria_hidden: true, class: 'crayons-icon', title: 'email')}Sign up with Email".html_safe,
24 | request.params.merge(state: "email_signup").except("i"),
25 | class: "crayons-btn crayons-btn--l crayons-btn--brand-email crayons-btn--icon-left whitespace-nowrap",
26 | data: { no_instant: "" }
27 | CODE
28 | app = Spektr::App.new(checks: [Spektr::Checks::LinkToHref])
29 | view = Spektr::Targets::View.new("index.html.erb", code)
30 | check = Spektr::Checks::LinkToHref.new(app, view)
31 | check.run
32 | assert_equal 2, app.warnings.size
33 | end
34 |
35 | def test_it_does_not_fail_with_url_helpers
36 | code = <<-CODE
37 | <%= link_to school.activities.count, school_activities_path(params[:id]) %>
38 | <%= link_to school.activities.count, school_activities_path(params[:id]) %>
39 | CODE
40 | app = Spektr::App.new(checks: [Spektr::Checks::LinkToHref])
41 | view = Spektr::Targets::View.new("index.html.erb", code)
42 | check = Spektr::Checks::LinkToHref.new(app, view)
43 | check.run
44 | assert_equal 0, app.warnings.size
45 | end
46 | end
47 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/public/500.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | We're sorry, but something went wrong (500)
5 |
6 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
We're sorry, but something went wrong.
62 |
63 |
If you are the application owner check the logs for more information.
64 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config/puma.rb:
--------------------------------------------------------------------------------
1 | # Puma can serve each request in a thread from an internal thread pool.
2 | # The `threads` method setting takes two numbers: a minimum and maximum.
3 | # Any libraries that use thread pools should be configured to match
4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum
5 | # and maximum; this matches the default thread size of Active Record.
6 | #
7 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
8 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }
9 | threads min_threads_count, max_threads_count
10 |
11 | # Specifies the `worker_timeout` threshold that Puma will use to wait before
12 | # terminating a worker in development environments.
13 | #
14 | worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development"
15 |
16 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000.
17 | #
18 | port ENV.fetch("PORT") { 3000 }
19 |
20 | # Specifies the `environment` that Puma will run in.
21 | #
22 | environment ENV.fetch("RAILS_ENV") { "development" }
23 |
24 | # Specifies the `pidfile` that Puma will use.
25 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }
26 |
27 | # Specifies the number of `workers` to boot in clustered mode.
28 | # Workers are forked web server processes. If using threads and workers together
29 | # the concurrency of the application would be max `threads` * `workers`.
30 | # Workers do not work on JRuby or Windows (both of which do not support
31 | # processes).
32 | #
33 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 }
34 |
35 | # Use the `preload_app!` method when specifying a `workers` number.
36 | # This directive tells Puma to first boot the application and load code
37 | # before forking the application. This takes advantage of Copy On Write
38 | # process behavior so workers use less memory.
39 | #
40 | # preload_app!
41 |
42 | # Allow puma to be restarted by `rails restart` command.
43 | plugin :tmp_restart
44 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/public/422.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | The change you wanted was rejected (422)
5 |
6 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
The change you wanted was rejected.
62 |
Maybe you tried to change something you didn't have access to.
63 |
64 |
If you are the application owner check the logs for more information.
65 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | The page you were looking for doesn't exist (404)
5 |
6 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
The page you were looking for doesn't exist.
62 |
You may have mistyped the address or the page may have moved.
63 |
64 |
If you are the application owner check the logs for more information.
65 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/spektr.gemspec:
--------------------------------------------------------------------------------
1 | require_relative 'lib/spektr/version'
2 |
3 | Gem::Specification.new do |spec|
4 | spec.name = 'spektr'
5 | spec.version = Spektr::VERSION
6 | spec.authors = ['Greg Molnar']
7 | spec.email = ['molnargerg@gmail.com']
8 |
9 | spec.summary = 'Rails static code analyzer for security issues'
10 | spec.description = 'Rails static code analyzer for security issues'
11 | spec.homepage = 'https://spektrhq.com'
12 | spec.license = 'Spektr Custom Licence'
13 | spec.required_ruby_version = Gem::Requirement.new('>= 2.3.0')
14 |
15 | # spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'"
16 |
17 | spec.metadata['homepage_uri'] = spec.homepage
18 | spec.metadata['source_code_uri'] = 'https://github.com/gregmolnar/spektr'
19 | # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
20 |
21 | # Specify which files should be added to the gem when it is released.
22 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
23 | spec.files = Dir.chdir(File.expand_path(__dir__)) do
24 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
25 | end
26 | spec.bindir = 'bin'
27 | spec.executables = 'spektr'
28 | spec.require_paths = ['lib']
29 |
30 | spec.add_dependency 'erubi'
31 | spec.add_dependency 'herb'
32 | spec.add_dependency 'haml'
33 | spec.add_dependency 'pastel'
34 | spec.add_dependency 'prism'
35 | spec.add_dependency 'ruby_parser', '>= 3.0'
36 | spec.add_dependency 'slim'
37 | spec.add_dependency 'tty-color'
38 | spec.add_dependency 'tty-option'
39 | spec.add_dependency 'tty-spinner'
40 | spec.add_dependency 'tty-table'
41 | spec.add_dependency 'zeitwerk', '>= 2.6'
42 |
43 | spec.add_development_dependency 'byebug'
44 | spec.add_development_dependency 'guard'
45 | spec.add_development_dependency 'guard-minitest'
46 | spec.add_development_dependency 'minitest', '~> 5.0'
47 | spec.add_development_dependency 'rake', '~> 12.0'
48 | spec.add_development_dependency 'rubocop'
49 | end
50 |
--------------------------------------------------------------------------------
/Guardfile:
--------------------------------------------------------------------------------
1 | # A sample Guardfile
2 | # More info at https://github.com/guard/guard#readme
3 |
4 | ## Uncomment and set this to only include directories you want to watch
5 | # directories %w(app lib config test spec features) \
6 | # .select{|d| Dir.exist?(d) ? d : UI.warning("Directory #{d} does not exist")}
7 |
8 | ## Note: if you are using the `directories` clause above and you are not
9 | ## watching the project directory ('.'), then you will want to move
10 | ## the Guardfile to a watched dir and symlink it back, e.g.
11 | #
12 | # $ mkdir config
13 | # $ mv Guardfile config/
14 | # $ ln -s config/Guardfile .
15 | #
16 | # and, you'll have to watch "config/Guardfile" instead of "Guardfile"
17 |
18 | guard :minitest do
19 | # with Minitest::Unit
20 | watch(%r{^test/(.*)\/?(.*)_test\.rb$})
21 | watch(%r{^lib/(.*/)?([^/]+)\.rb$}) { |m|
22 | puts "test/#{m[1]}#{m[2]}_test.rb"
23 | "test/#{m[1]}#{m[2]}_test.rb"
24 | }
25 | watch(%r{^test/test_helper\.rb$}) { 'test' }
26 |
27 | # with Minitest::Spec
28 | # watch(%r{^spec/(.*)_spec\.rb$})
29 | # watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
30 | # watch(%r{^spec/spec_helper\.rb$}) { 'spec' }
31 |
32 | # Rails 4
33 | # watch(%r{^app/(.+)\.rb$}) { |m| "test/#{m[1]}_test.rb" }
34 | # watch(%r{^app/controllers/application_controller\.rb$}) { 'test/controllers' }
35 | # watch(%r{^app/controllers/(.+)_controller\.rb$}) { |m| "test/integration/#{m[1]}_test.rb" }
36 | # watch(%r{^app/views/(.+)_mailer/.+}) { |m| "test/mailers/#{m[1]}_mailer_test.rb" }
37 | # watch(%r{^lib/(.+)\.rb$}) { |m| "test/lib/#{m[1]}_test.rb" }
38 | # watch(%r{^test/.+_test\.rb$})
39 | # watch(%r{^test/test_helper\.rb$}) { 'test' }
40 |
41 | # Rails < 4
42 | # watch(%r{^app/controllers/(.*)\.rb$}) { |m| "test/functional/#{m[1]}_test.rb" }
43 | # watch(%r{^app/helpers/(.*)\.rb$}) { |m| "test/helpers/#{m[1]}_test.rb" }
44 | # watch(%r{^app/models/(.*)\.rb$}) { |m| "test/unit/#{m[1]}_test.rb" }
45 | end
46 |
--------------------------------------------------------------------------------
/test/spektr/checks/sqli_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class SqliTest < Minitest::Test
4 | def setup
5 | @app = Spektr::App.new(checks: [Spektr::Checks::Sqli])
6 | end
7 |
8 | def test_it_fails_with_dangerous_methods_and_user_input
9 | code = <<-CODE
10 | class BlogController
11 | def index
12 | Post.count(:id)
13 | Post.average(params[:column])
14 | Post.count(params[:column])
15 | Post.maximum(params[:column])
16 | Post.minimum(params[:column])
17 | Post.sum(params[:column])
18 |
19 | Post.calculate(:sum, :total)
20 | Post.calculate(:sum, params[:cookie])
21 |
22 | Post.delete_by(id: 1)
23 | Post.delete_by(params[:key] => 1)
24 | Post.delete_by(id: params[:key])
25 | Post.delete_by("id=\#{params[:id]}")
26 |
27 | Post.exists?(1)
28 | Post.exists?(params[:id])
29 |
30 | Post.find_by(params[:id])
31 | Post.find_by!(params[:id])
32 | Post.find_or_create_by(params[:id])
33 | Post.find_or_create_by!(params[:id])
34 | Post.find_or_initialize_by(params[:id])
35 |
36 | Post.from(params[:from])
37 | Post.group(params[:group])
38 | Post.having(params[:having])
39 | Post.join(params[:join])
40 | Post.lock(params[:lock])
41 |
42 | Post.where(params[:q])
43 | Post.where("id = \#{params[:q]}")
44 | Post.where.not(params[:q])
45 | Post.rewhere(params[:q])
46 |
47 | Post.select(params[:field])
48 | Post.reselect(params[:field])
49 |
50 | Post.update_all(params[:q])
51 | term = params[:term]
52 | Product.find_by_sql("SELECT * FROM products WHERE title LIKE '%\#{term}%'")
53 | end
54 | end
55 | CODE
56 | controller = Spektr::Targets::Controller.new("blog_controller.rb", code)
57 | check = Spektr::Checks::Sqli.new(@app, controller)
58 | check.run
59 | assert_equal 28, @app.warnings.size
60 | end
61 | end
62 |
--------------------------------------------------------------------------------
/lib/spektr/checks/command_injection.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class CommandInjection < Base
4 | def initialize(app, target)
5 | super
6 | @name = "Command Injection"
7 | @type = "Command Injection"
8 | @targets = ["Spektr::Targets::Base", "Spektr::Targets::Controller", "Spektr::Targets::Model", "Spektr::Targets::Routes", "Spektr::Targets::View"]
9 | end
10 |
11 | def run
12 | return unless super
13 | # backticks
14 | @target.interpolated_xstrings.each do |call|
15 | call.parts.each do |part|
16 | if user_input?(part)
17 | warn! @target, self, call.location, "Command injection"
18 | end
19 | end
20 | end
21 |
22 | targets = [:IO, :Open3, :Kernel, :Spawn, :Process, false]
23 | methods = [:capture2, :capture2e, :capture3, :exec, :pipeline, :pipeline_r,
24 | :pipeline_rw, :pipeline_start, :pipeline_w, :popen, :popen2, :popen2e,
25 | :popen3, :spawn, :syscall, :system, :open]
26 | targets.each do |target|
27 | methods.each do |method|
28 | check_calls(@target.find_calls(method, target))
29 | end
30 | end
31 | end
32 |
33 | def check_calls(calls)
34 | # TODO: might need to exclude tempfile and ActiveStorage::Filename
35 | return if calls.empty?
36 | calls.each do |call|
37 | if call.arguments.is_a?(Prism::ArgumentsNode)
38 | argument = call.arguments.arguments.first
39 | else
40 | argument = call.arguments.first
41 | end
42 | next unless argument
43 | if user_input?(argument) || model_attribute?(argument)
44 | warn! @target, self, call.location, "Command injection in #{call.name}"
45 | # TODO: interpolation, but might be safe, we should make this better
46 | elsif argument.type == :embedded_statements_node
47 | warn! @target, self, call.location, "Command injection in #{call.name}", :low
48 | end
49 | end
50 | end
51 | end
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/lib/spektr/checks/xss.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class Xss < Base
4 | def initialize(app, target)
5 | super
6 | @name = "XSS"
7 | @type = "Cross-Site Scripting"
8 | @targets = ["Spektr::Targets::Base", "Spektr::Targets::Controller", "Spektr::Targets::View"]
9 | end
10 |
11 | # TODO: tests for haml, xml, js
12 | # TODO: add check for raw calls
13 | def run
14 | return unless super
15 | calls = @target.find_calls(:safe_expr_append=)
16 | calls.concat(@target.find_calls(:raw))
17 | calls.each do |call|
18 | ::Spektr.logger.debug "Checking arguments in #{@target.path} at line #{call.location.start_line}"
19 | call.arguments.arguments.each do |argument|
20 | if user_input?(argument)
21 | warn! @target, self, call.location, "Cross-Site Scripting: Unescaped user input"
22 | end
23 | if model_attribute?(argument)
24 | warn! @target, self, call.location, "Cross-Site Scripting: Unescaped model attribute"
25 | end
26 | end
27 | end
28 | calls.each do |call|
29 | ::Spektr.logger.debug "Checking arguments in #{@target.path} at line #{call.location.start_line}"
30 | call.arguments.arguments.each do |argument|
31 | if user_input?(argument)
32 | warn! @target, self, call.location, "Cross-Site Scripting: Unescaped user input"
33 | end
34 | if model_attribute?(argument)
35 | warn! @target, self, call.location, "Cross-Site Scripting: Unescaped model attribute"
36 | end
37 | end
38 | end
39 | calls = @target.find_calls(:html_safe)
40 | calls.each do |call|
41 | ::Spektr.logger.debug "Checking arguments in #{@target.path} at line #{call.location.start_line}"
42 | if user_input?(call.receiver)
43 | warn! @target, self, call.location, "Cross-Site Scripting: Unescaped user input"
44 | end
45 | if model_attribute?(call.receiver)
46 | warn! @target, self, call.location, "Cross-Site Scripting: Unescaped model attribute #{call.receiver.name}"
47 | end
48 | end
49 | end
50 | end
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/lib/spektr/erubi.rb:
--------------------------------------------------------------------------------
1 | require "erubi"
2 | # This is a copy of module ActionView::Template::Handlers::ERB::Erubi
3 | module Spektr
4 | class Erubi < ::Erubi::Engine
5 | def initialize(input, properties = {})
6 | @newline_pending = 0
7 |
8 | # Dup properties so that we don't modify argument
9 | properties = Hash[properties]
10 | properties[:preamble] = ""
11 | properties[:postamble] = "@output_buffer.to_s"
12 | properties[:bufvar] = "@output_buffer"
13 | properties[:escapefunc] = ""
14 |
15 | super
16 | end
17 |
18 | def evaluate(action_view_erb_handler_context)
19 | src = @src
20 | view = Class.new(ActionView::Base) {
21 | include action_view_erb_handler_context._routes.url_helpers
22 | class_eval("define_method(:_template) { |local_assigns, output_buffer| #{src} }", defined?(@filename) ? @filename : "(erubi)", 0)
23 | }.empty
24 | view._run(:_template, nil, {}, ActionView::OutputBuffer.new)
25 | end
26 |
27 | private
28 | def add_text(text)
29 | return if text.empty?
30 |
31 | if text == "\n"
32 | @newline_pending += 1
33 | else
34 | src << "@output_buffer.safe_append='"
35 | src << "\n" * @newline_pending if @newline_pending > 0
36 | src << text.gsub(/['\\]/, '\\\\\&')
37 | src << "'.freeze;"
38 |
39 | @newline_pending = 0
40 | end
41 | end
42 |
43 | BLOCK_EXPR = /\s*((\s+|\))do|\{)(\s*\|[^|]*\|)?\s*\Z/
44 |
45 | def add_expression(indicator, code)
46 | flush_newline_if_pending(src)
47 |
48 | if (indicator == "==") || @escape
49 | src << "@output_buffer.safe_expr_append="
50 | else
51 | src << "@output_buffer.append="
52 | end
53 |
54 | if BLOCK_EXPR.match?(code)
55 | src << " " << code
56 | else
57 | src << "(" << code << ");"
58 | end
59 | end
60 |
61 | def add_code(code)
62 | flush_newline_if_pending(src)
63 | super
64 | end
65 |
66 | def add_postamble(_)
67 | flush_newline_if_pending(src)
68 | super
69 | end
70 |
71 | def flush_newline_if_pending(src)
72 | if @newline_pending > 0
73 | src << "@output_buffer.safe_append='#{"\n" * @newline_pending}'.freeze;"
74 | @newline_pending = 0
75 | end
76 | end
77 | end
78 | end
79 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" }
3 |
4 | ruby '2.7.2'
5 |
6 | # Bundle edge Rails instead: gem 'rails', github: 'rails/rails', branch: 'main'
7 | gem 'rails', '~> 6.1.4'
8 | # Use sqlite3 as the database for Active Record
9 | gem 'sqlite3', '~> 1.4'
10 | # Use Puma as the app server
11 | gem 'puma', '~> 5.0'
12 | # Use SCSS for stylesheets
13 | gem 'sass-rails', '>= 6'
14 | # Transpile app-like JavaScript. Read more: https://github.com/rails/webpacker
15 | gem 'webpacker', '~> 5.0'
16 | # Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks
17 | gem 'turbolinks', '~> 5'
18 | # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
19 | gem 'jbuilder', '~> 2.7'
20 | # Use Redis adapter to run Action Cable in production
21 | # gem 'redis', '~> 4.0'
22 | # Use Active Model has_secure_password
23 | # gem 'bcrypt', '~> 3.1.7'
24 |
25 | # Use Active Storage variant
26 | # gem 'image_processing', '~> 1.2'
27 |
28 | # Reduces boot times through caching; required in config/boot.rb
29 | gem 'bootsnap', '>= 1.4.4', require: false
30 |
31 | group :development, :test do
32 | # Call 'byebug' anywhere in the code to stop execution and get a debugger console
33 | gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
34 | end
35 |
36 | group :development do
37 | # Access an interactive console on exception pages or by calling 'console' anywhere in the code.
38 | gem 'web-console', '>= 4.1.0'
39 | # Display performance information such as SQL time and flame graphs for each request in your browser.
40 | # Can be configured to work on production as well see: https://github.com/MiniProfiler/rack-mini-profiler/blob/master/README.md
41 | gem 'rack-mini-profiler', '~> 2.0'
42 | gem 'listen', '~> 3.3'
43 | # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring
44 | gem 'spring'
45 | gem 'brakeman'
46 | end
47 |
48 | group :test do
49 | # Adds support for Capybara system testing and selenium driver
50 | gem 'capybara', '>= 3.26'
51 | gem 'selenium-webdriver'
52 | # Easy installation and use of web drivers to run system tests with browsers
53 | gem 'webdrivers'
54 | end
55 |
56 | # Windows does not include zoneinfo files, so bundle the tzinfo-data gem
57 | gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]
58 |
--------------------------------------------------------------------------------
/test/spektr/checks/command_injection_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class CommandInjectionTest < Minitest::Test
4 | def setup
5 | @code = <<-CODE
6 | class ApplicationController
7 | def index
8 | `ls /home`
9 | `ls \#{params[:directory]}`
10 | Kernel.open(params[:directory])
11 | end
12 | end
13 | CODE
14 | @app = Spektr::App.new(checks: [Spektr::Checks::CommandInjection])
15 | @controller = Spektr::Targets::Controller.new("application_controller.rb", @code)
16 | @check = Spektr::Checks::CommandInjection.new(@app, @controller)
17 | end
18 |
19 | def test_it_fails_with_user_supplied_value
20 | @check.run
21 | assert_equal 2, @app.warnings.size
22 | end
23 |
24 | def test_it_fails_with_interpolation
25 | skip "check this test"
26 | code = <<-CODE
27 | class Benefits
28 | def self.make_backup(file, data_path, full_file_name)
29 | if File.exist?(full_file_name)
30 | silence_streams(STDERR) { system("cp \#{full_file_name} \#{data_path}/bak\#{Time.zone.now.to_i}_\#{file.original_filename}") }
31 | end
32 | end
33 | end
34 | CODE
35 |
36 | app = Spektr::App.new(checks: [Spektr::Checks::CommandInjection])
37 | model = Spektr::Targets::Model.new("benefits.rb", code)
38 | app.models = [model]
39 | check = Spektr::Checks::CommandInjection.new(app, model)
40 | check.run
41 | assert_equal 1, app.warnings.size
42 | end
43 |
44 | def test_it_fails_with_exec
45 | code = <<-CODE
46 | exec("ls \#{params[:directory]}")
47 | CODE
48 | app = Spektr::App.new(checks: [Spektr::Checks::CommandInjection])
49 | model = Spektr::Targets::Model.new("benefits.rb", code)
50 | app.models = [model]
51 | check = Spektr::Checks::CommandInjection.new(app, model)
52 | check.run
53 | assert_equal 1, app.warnings.size
54 | end
55 |
56 |
57 | def test_it_does_not_fail_on_db_exec
58 | code = <<-CODE
59 | rows = DB.exec(<<~SQL, args)
60 | UPDATE post_timings
61 | SET msecs = msecs + :msecs
62 | WHERE topic_id = :topic_id
63 | AND user_id = :user_id
64 | AND post_number = :post_number
65 | SQL
66 | CODE
67 | app = Spektr::App.new(checks: [Spektr::Checks::CommandInjection])
68 | model = Spektr::Targets::Model.new("benefits.rb", code)
69 | app.models = [model]
70 | check = Spektr::Checks::CommandInjection.new(app, model)
71 | check.run
72 | assert_equal 0, app.warnings.size
73 | end
74 | end
75 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config/environments/test.rb:
--------------------------------------------------------------------------------
1 | require "active_support/core_ext/integer/time"
2 |
3 | # The test environment is used exclusively to run your application's
4 | # test suite. You never need to work with it otherwise. Remember that
5 | # your test database is "scratch space" for the test suite and is wiped
6 | # and recreated between test runs. Don't rely on the data there!
7 |
8 | Rails.application.configure do
9 | # Settings specified here will take precedence over those in config/application.rb.
10 |
11 | config.cache_classes = false
12 | config.action_view.cache_template_loading = true
13 |
14 | # Do not eager load code on boot. This avoids loading your whole application
15 | # just for the purpose of running a single test. If you are using a tool that
16 | # preloads Rails for running tests, you may have to set it to true.
17 | config.eager_load = false
18 |
19 | # Configure public file server for tests with Cache-Control for performance.
20 | config.public_file_server.enabled = true
21 | config.public_file_server.headers = {
22 | 'Cache-Control' => "public, max-age=#{1.hour.to_i}"
23 | }
24 |
25 | # Show full error reports and disable caching.
26 | config.consider_all_requests_local = true
27 | config.action_controller.perform_caching = false
28 | config.cache_store = :null_store
29 |
30 | # Raise exceptions instead of rendering exception templates.
31 | config.action_dispatch.show_exceptions = false
32 |
33 | # Disable request forgery protection in test environment.
34 | config.action_controller.allow_forgery_protection = false
35 |
36 | # Store uploaded files on the local file system in a temporary directory.
37 | config.active_storage.service = :test
38 |
39 | config.action_mailer.perform_caching = false
40 |
41 | # Tell Action Mailer not to deliver emails to the real world.
42 | # The :test delivery method accumulates sent emails in the
43 | # ActionMailer::Base.deliveries array.
44 | config.action_mailer.delivery_method = :test
45 |
46 | # Print deprecation notices to the stderr.
47 | config.active_support.deprecation = :stderr
48 |
49 | # Raise exceptions for disallowed deprecations.
50 | config.active_support.disallowed_deprecation = :raise
51 |
52 | # Tell Active Support which deprecation messages to disallow.
53 | config.active_support.disallowed_deprecation_warnings = []
54 |
55 | # Raises error for missing translations.
56 | # config.i18n.raise_on_missing_translations = true
57 |
58 | # Annotate rendered view with file names.
59 | # config.action_view.annotate_rendered_view_with_filenames = true
60 | end
61 |
--------------------------------------------------------------------------------
/test/spektr/checks/deserialize_test.rb:
--------------------------------------------------------------------------------
1 | require "test_helper"
2 |
3 | class DeserializeTest < Minitest::Test
4 |
5 | def test_with_csv_load
6 | code = <<-CODE
7 | class ApplicationController
8 | def index
9 | CSV.load(params[:path])
10 | CSV.load("test.csv")
11 | end
12 | end
13 | CODE
14 | app = Spektr::App.new(checks: [Spektr::Checks::Deserialize])
15 | controller = Spektr::Targets::Controller.new("application_controller.rb", code)
16 | check = Spektr::Checks::Deserialize.new(app, controller)
17 | check.run
18 | assert_equal 1, app.warnings.size
19 | end
20 |
21 | def test_with_yaml
22 | code = <<-CODE
23 | class ApplicationController
24 | def index
25 | YAML.load_documents(params[:path])
26 | YAML.load_stream(params[:path])
27 | YAML.parse_documents(params[:path])
28 | YAML.parse_stream(params[:path])
29 | end
30 | end
31 | CODE
32 | app = Spektr::App.new(checks: [Spektr::Checks::Deserialize])
33 | controller = Spektr::Targets::Controller.new("application_controller.rb", code)
34 | check = Spektr::Checks::Deserialize.new(app, controller)
35 | check.run
36 | assert_equal 4, app.warnings.size
37 | end
38 |
39 | def test_with_marshal
40 | code = <<-CODE
41 | class ApplicationController
42 | def index
43 | Marshal.load("test")
44 | Marshal.load(params[:path])
45 | Marshal.restore(params[:path])
46 | Marshal.load(Base64.decode64(params[:user]))
47 | end
48 | end
49 | CODE
50 | app = run_check(code)
51 | assert_equal 3, app.warnings.size
52 | end
53 |
54 | def test_with_oj
55 | code = <<-CODE
56 | class ApplicationController
57 | def index
58 | Oj.object_load("test")
59 | Oj.object_load(params[:path])
60 | Oj.load(params[:path])
61 | end
62 | end
63 | CODE
64 | app = run_check(code)
65 | assert_equal 2, app.warnings.size
66 |
67 | # with safe mode
68 | code = <<-CODE
69 | class ApplicationController
70 | def index
71 | Oj.default_options = { mode: :strict }
72 | Oj.object_load("test")
73 | Oj.object_load(params[:path])
74 | Oj.load(params[:path])
75 | end
76 | end
77 | CODE
78 | app = run_check(code)
79 | assert_equal 1, app.warnings.size
80 | end
81 |
82 | def run_check(code)
83 | app = Spektr::App.new(checks: [Spektr::Checks::Deserialize])
84 | controller = Spektr::Targets::Controller.new("application_controller.rb", code)
85 | check = Spektr::Checks::Deserialize.new(app, controller)
86 | check.run
87 | app
88 | end
89 | end
90 |
--------------------------------------------------------------------------------
/lib/spektr/checks/content_tag_xss.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks
3 | class ContentTagXss < Base
4 | # Checks for unescaped values in `content_tag`
5 | #
6 | # content_tag :tag, body
7 | # ^-- Unescaped in Rails 2.x
8 | #
9 | # content_tag, :tag, body, attribute => value
10 | # ^-- Unescaped in all versions
11 | # TODO:
12 | # content_tag, :tag, body, attribute => value
13 | # ^
14 | # |
15 | # Escaped by default, can be explicitly escaped
16 | # or not by passing in (true|false) as fourth argument
17 | def initialize(app, target)
18 | super
19 | @name = "XSS in content_tag"
20 | @type = "Cross-Site Scripting"
21 | @targets = ["Spektr::Targets::Base", "Spektr::Targets::View"]
22 | end
23 |
24 | def run
25 | return unless super
26 | return unless @app.rails_version
27 | calls = @target.find_calls(:content_tag)
28 | # https://groups.google.com/d/msg/ruby-security-ann/8B2iV2tPRSE/JkjCJkSoCgAJ
29 | cve_2016_6316_check(calls)
30 |
31 | calls.each do |call|
32 | call.arguments.is_a?(Prism::ArgumentsNode) ? arguments = call.arguments.arguments : call.arguments
33 | arguments.each do |argument|
34 | if user_input?(argument) && @app.rails_version < Gem::Version.new("3.0")
35 | warn! @target, self, call.location, "Unescaped parameter in content_tag in Rails < 3.0"
36 | end
37 | if argument.is_a?(Prism::KeywordHashNode)
38 | argument.elements.each do |element|
39 | if user_input?(element.key)
40 | warn! @target, self, call.location, "Unescaped parameter in content_tag at #{element.key.name}"
41 | end
42 | end
43 | end
44 | end
45 |
46 | # if call.options.any?
47 | # call.options.each_value do |option|
48 | # if user_input?(option.key.type, option.key.children.last)
49 | # warn! @target, self, call.location, "Unescaped attribute name in content_tag"
50 | # end
51 | # end
52 | # end
53 | end
54 | end
55 |
56 | def cve_2016_6316_check(calls)
57 | if calls.any? && app_version_between?("3.0.0", "3.2.22.3") || app_version_between?("4.0.0", "4.2.7.0") || app_version_between?("5.0.0", "5.0.0.0")
58 | warn! @target, self, calls.first.location, "Rails #{@app.rails_version} does not escape double quotes in attribute values"
59 | end
60 | end
61 | end
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/lib/spektr/targets/controller.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | module Targets
3 | class Controller < Base
4 |
5 | def initialize(path, content)
6 | super
7 | end
8 |
9 | def concern?
10 | !name.match('Controller')
11 | end
12 |
13 | def actions
14 | @actions ||= public_methods.map do |node|
15 | Action.new(node, self)
16 | end
17 | end
18 |
19 | def find_action(action)
20 | @actions.find{|a| a.name == action }
21 | end
22 |
23 | def find_parent(controllers)
24 | result = find_in_set(@parent, controllers)
25 | # result ||= find_in_set(processor.parent_name_with_modules, controllers)
26 | return nil if result&.name == name
27 |
28 | result
29 | end
30 |
31 | def find_in_set(name, set)
32 | while true
33 | result = set.find { |c| c.name == name }
34 | break if result
35 |
36 | split = name.split('::')
37 | split.shift
38 | name = split.join('::')
39 | break if name.blank?
40 | end
41 | result
42 | end
43 |
44 | class Action
45 | attr_accessor :node, :name, :controller, :template
46 |
47 | def initialize(node, controller)
48 | @node = node
49 | @name = node.name
50 | @template = nil
51 | @controller = controller
52 | split = []
53 | if controller.parent && controller.parent != 'ApplicationController'
54 | split = controller.parent.split('::').map { |e| e.delete_suffix('Controller') }.map(&:downcase)
55 | if split.size > 1
56 | split.pop
57 | @template = "#{split.join('/')}/#{@template}"
58 | end
59 | end
60 |
61 | split = split.concat(controller.name.split('::').map do |n|
62 | n.delete_suffix('Controller')
63 | end.map(&:downcase)).uniq
64 | split.delete('application')
65 | @template = File.join(*split, name.to_s)
66 | # TODO: set template from render
67 | # @body.each do |exp|
68 | # if exp.send? && exp.name == :render && exp.arguments.any?
69 | # if exp.arguments.first.type == :sym
70 | # @template = File.join(controller.name.delete_suffix('Controller').underscore,
71 | # exp.arguments.first.name.to_s)
72 | # elsif exp.arguments.first.type == :str
73 | # @template = exp.arguments.first.name
74 | # end
75 | # end
76 | # end
77 | end
78 |
79 | def body
80 | @node.body.respond_to?(:body) ? @node.body.body : @node.body
81 | end
82 | end
83 | end
84 | end
85 | end
86 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config/environments/development.rb:
--------------------------------------------------------------------------------
1 | require "active_support/core_ext/integer/time"
2 |
3 | Rails.application.configure do
4 | # Settings specified here will take precedence over those in config/application.rb.
5 |
6 | # In the development environment your application's code is reloaded any time
7 | # it changes. This slows down response time but is perfect for development
8 | # since you don't have to restart the web server when you make code changes.
9 | config.cache_classes = false
10 |
11 | # Do not eager load code on boot.
12 | config.eager_load = false
13 |
14 | # Show full error reports.
15 | config.consider_all_requests_local = true
16 |
17 | # Enable/disable caching. By default caching is disabled.
18 | # Run rails dev:cache to toggle caching.
19 | if Rails.root.join('tmp', 'caching-dev.txt').exist?
20 | config.action_controller.perform_caching = true
21 | config.action_controller.enable_fragment_cache_logging = true
22 |
23 | config.cache_store = :memory_store
24 | config.public_file_server.headers = {
25 | 'Cache-Control' => "public, max-age=#{2.days.to_i}"
26 | }
27 | else
28 | config.action_controller.perform_caching = false
29 |
30 | config.cache_store = :null_store
31 | end
32 |
33 | # Store uploaded files on the local file system (see config/storage.yml for options).
34 | config.active_storage.service = :local
35 |
36 | # Don't care if the mailer can't send.
37 | config.action_mailer.raise_delivery_errors = false
38 |
39 | config.action_mailer.perform_caching = false
40 |
41 | # Print deprecation notices to the Rails logger.
42 | config.active_support.deprecation = :log
43 |
44 | # Raise exceptions for disallowed deprecations.
45 | config.active_support.disallowed_deprecation = :raise
46 |
47 | # Tell Active Support which deprecation messages to disallow.
48 | config.active_support.disallowed_deprecation_warnings = []
49 |
50 | # Raise an error on page load if there are pending migrations.
51 | config.active_record.migration_error = :page_load
52 |
53 | # Highlight code that triggered database queries in logs.
54 | config.active_record.verbose_query_logs = true
55 |
56 | # Debug mode disables concatenation and preprocessing of assets.
57 | # This option may cause significant delays in view rendering with a large
58 | # number of complex assets.
59 | config.assets.debug = true
60 |
61 | # Suppress logger output for asset requests.
62 | config.assets.quiet = true
63 |
64 | # Raises error for missing translations.
65 | # config.i18n.raise_on_missing_translations = true
66 |
67 | # Annotate rendered view with file names.
68 | # config.action_view.annotate_rendered_view_with_filenames = true
69 |
70 | # Use an evented file watcher to asynchronously detect changes in source code,
71 | # routes, locales, etc. This feature depends on the listen gem.
72 | config.file_watcher = ActiveSupport::EventedFileUpdateChecker
73 |
74 | # Uncomment if you wish to allow Action Cable access from any origin.
75 | # config.action_cable.disable_request_forgery_protection = true
76 | end
77 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Spektr
2 |
3 | [](https://github.com/gregmolnar/spektr/actions/workflows/ci.yaml)
4 |
5 | Spektr is a static-code analyser for Ruby On Rails applications to find security issues.
6 |
7 | ## Installation
8 |
9 | Add this line to your application's Gemfile:
10 |
11 | ```ruby
12 | gem 'spektr'
13 | ```
14 |
15 | And then execute:
16 |
17 | $ bundle install
18 |
19 | Or install it yourself as:
20 |
21 | $ gem install spektr
22 |
23 | ## Usage
24 |
25 | If you are using in your app:
26 |
27 | ```
28 | spektr
29 | ```
30 |
31 | If you want to scan an app in another folder:
32 |
33 | ```
34 | spektr path/to/app
35 | ```
36 |
37 | To see the available options, you can run `spektr --help`.
38 |
39 | To ignore a finding, you can use the `--ignore` flag with a comma separated list of fingerprints from the report.
40 |
41 |
42 | ### Railsgoat Example output
43 |
44 | 
45 |
46 | ### False positives
47 |
48 | Due to the nature of static-code analysis, Spektr might report false positives. Please report them, so I can try
49 | to tweak the check.
50 |
51 |
52 | ## Development
53 |
54 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
55 |
56 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
57 |
58 | ## Contributing
59 |
60 | Bug reports and pull requests are welcome on GitHub at https://github.com/gregmolnar/spektr. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/gregmolnar/spektr/blob/master/CODE_OF_CONDUCT.md).
61 |
62 |
63 | ## License
64 |
65 | The gem is available as open source under the terms described in the [licence](https://github.com/gregmolnar/spektr/blob/master/LICENSE.txt). Non-commercial use is free of charge, to obtain a commercial licence, contact us at info[at]spektrhq.com.
66 | If you are looking for a hosted solution, take a look at [SpektrHQ](https://spektrhq.com).
67 |
68 |
69 | ## Code of Conduct
70 |
71 | Everyone interacting in the Spektr project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/gregmolnar/spektr/blob/master/CODE_OF_CONDUCT.md).
72 |
73 | ## FAQ
74 |
75 | ### I use Spektr in my closed-source paid product making millions of dollars, is that non-commercial use?
76 |
77 | Yes, this is perfectly fine without obtaining a licence. You can however donate to the development here on Github.
78 |
79 | ### I want to use Spektr in my automated code analyser SaaS, do I need a commercial licence?
80 |
81 | Yes, please get in touch at info[at]spektrhq.com and we will work something out.
82 |
83 | ### I am a penetration tester and I'd like to use Spektr to audit on a paid engagement. Do I need a commercial licence?
84 |
85 | No. You are free to use it for that purpose, happy bug hunting!
86 |
--------------------------------------------------------------------------------
/lib/spektr.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'bundler'
4 | require 'prism'
5 | require 'erb'
6 | require 'herb'
7 | require 'haml'
8 | require 'logger'
9 | require 'tty/spinner'
10 | require 'tty/table'
11 | require 'spektr/core_ext/string'
12 | require 'zeitwerk'
13 | loader = Zeitwerk::Loader.for_gem
14 | loader.collapse("#{__dir__}/processors")
15 | loader.do_not_eager_load("#{__dir__}/spektr/core_ext")
16 | loader.setup
17 |
18 | module Spektr
19 | class Error < StandardError; end
20 |
21 | def self.run(root = nil, output_format = 'terminal', debug = false, checks = nil, ignore = [])
22 | pastel = Pastel.new
23 | @output_format = output_format
24 | start_spinner('Initializing')
25 | @log_level = if debug
26 | Logger::DEBUG
27 | elsif terminal?
28 | Logger::ERROR
29 | else
30 | Logger::WARN
31 | end
32 | checks = Checks.load(checks)
33 | root = './' if root.nil?
34 | @app = App.new(checks: checks, root: root, ignore: ignore)
35 | stop_spinner
36 | if terminal?
37 | puts "\n"
38 | puts pastel.bold('Checks:')
39 | puts "\n"
40 | puts checks.collect(&:name).join(', ')
41 | puts "\n"
42 | end
43 |
44 | start_spinner('Loading files')
45 | @app.load
46 | stop_spinner
47 | table = TTY::Table.new([
48 | ['Rails version', @app.rails_version],
49 | ['Initializers', @app.initializers.size],
50 | ['Controllers', @app.controllers.size],
51 | ['Models', @app.models.size],
52 | ['Views', @app.views.size],
53 | ['Routes', @app.routes.size],
54 | ['Lib files', @app.lib_files.size]
55 | ])
56 | if terminal?
57 | puts "\n"
58 | puts table.render(:basic)
59 | puts "\n"
60 | end
61 | start_spinner('Scanning files')
62 | @app.scan!
63 | stop_spinner
64 | puts "\n"
65 | json = @app.report
66 |
67 | case output_format
68 | when 'json'
69 | json
70 | when 'terminal'
71 | puts pastel.bold("Advisories\n")
72 |
73 | json[:advisories].each do |advisory|
74 | puts "#{pastel.green('Name:')} #{advisory[:name]}\n"
75 | puts "#{pastel.green('Check:')} #{advisory[:check]}\n"
76 | puts "#{pastel.green('Description:')} #{advisory[:description]}\n"
77 | puts "#{pastel.green('Path:')} #{advisory[:path]}\n"
78 | puts "#{pastel.green('Location:')} #{advisory[:location]}\n"
79 | puts "#{pastel.green('Code:')} #{advisory[:line]}\n"
80 | puts "#{pastel.green('Fingerprint:')} #{advisory[:fingerprint]}\n"
81 | puts "\n"
82 | puts "\n"
83 | end
84 |
85 | puts pastel.bold("Summary\n")
86 | summary = []
87 | json[:advisories].group_by { |a| a[:name] }.each do |n, i|
88 | summary << [pastel.green(n), i.size]
89 | end
90 |
91 | table = TTY::Table.new(summary, padding: [2, 2, 2, 2])
92 | puts table.render(:basic)
93 | puts "\n\n"
94 | exit 1 if json[:advisories].any?
95 | else
96 | puts 'Unknown format'
97 | end
98 | end
99 |
100 | def self.terminal?
101 | @output_format == 'terminal'
102 | end
103 |
104 | def self.start_spinner(label)
105 | return unless terminal?
106 |
107 | @spinner = TTY::Spinner.new("[:spinner] #{label}", format: :classic)
108 | @spinner.auto_spin
109 | end
110 |
111 | def self.stop_spinner
112 | return unless terminal?
113 |
114 | @spinner&.stop('Done!')
115 | end
116 |
117 | def self.swap_spinner(label)
118 | stop_spinner
119 | start_spinner(label)
120 | end
121 |
122 | def self.logger
123 | @logger ||= begin
124 | logger = Logger.new($stdout)
125 | logger.level = @log_level || Logger::WARN
126 | logger
127 | end
128 | end
129 | end
130 |
--------------------------------------------------------------------------------
/lib/spektr/targets/base.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | module Targets
3 | class Base < Prism::Visitor
4 | attr_accessor :path, :name, :options, :ast, :parent, :parent_modules, :methods, :calls, :interpolated_xstrings, :lvars
5 |
6 | def initialize(path, content)
7 | Spektr.logger.debug "loading #{path}"
8 | @ast = Prism.parse(content)
9 | @path = path
10 | return unless @ast
11 | @parent = ""
12 | @parent_modules = []
13 | @methods = []
14 | @lvars = []
15 | @ivars = []
16 | @calls = []
17 | @interpolated_xstrings = []
18 | @ast.value.accept(self)
19 | @name = @path.split('/').last if @name&.blank?
20 | @name = @name.prepend("#{@parent_modules.map(&:name).join('::')}::") if @name && @parent_modules.any?
21 | end
22 |
23 | def method_definitions
24 | @method_definitions ||= find_methods(node: @ast)
25 | end
26 |
27 | def public_methods
28 | @public_methods ||= find_methods(node: @ast, visibility: :public)
29 | end
30 |
31 | def find_calls(name, receiver = nil)
32 | if name.is_a? Regexp
33 | operator = :=~
34 | else
35 | operator = :==
36 | end
37 | @calls.select do |node|
38 | if receiver.nil?
39 | node.name.send(operator, name)
40 | elsif receiver == false
41 | node.name.send(operator, name) && node.receiver.nil?
42 | else
43 | node_receiver = node.receiver.name if node.receiver.respond_to?(:name)
44 | if node.receiver.respond_to?(:parent)
45 | node_receiver = node_receiver.to_s.prepend("#{node.receiver.parent.name}::").to_sym
46 | end
47 | node.name.send(operator, name) && node.receiver && receiver == node_receiver
48 | end
49 | end
50 | end
51 |
52 | def find_calls_with_block(name, _receiver = nil)
53 | find_calls(name).select do |call|
54 | call.block
55 | end
56 | end
57 |
58 | def find_method(name)
59 | @methods.find{|method| method.name == name }
60 | end
61 |
62 | def find_methods(node:, visibility: :all)
63 | Spektr::Extractors::Methods.new(visibility:).call(node).result
64 | end
65 |
66 |
67 | def visit_call_node(node)
68 | @calls << node
69 | super
70 | end
71 |
72 | def visit_class_node(node)
73 | @name = node.name.to_s
74 | case node.superclass
75 | when Prism::CallNode
76 | @parent = node.superclass.receiver.name.to_s
77 | when Prism::ConstantPathNode, Prism::ConstantReadNode
78 | @parent = node.superclass.name.to_s
79 | @parent.prepend("#{node.superclass.parent.name}::") if node.superclass.respond_to?(:parent)
80 | if node.superclass.respond_to?(:parent) && node.superclass.parent.respond_to?(:parent)
81 | @parent.prepend("#{node.superclass.parent.parent.name}::")
82 | end
83 | end
84 | if node.is_a?(Prism::ClassNode) && node.constant_path && node.constant_path.respond_to?(:parent)
85 | @parent = node.constant_path.parent.name.to_s
86 | end
87 | @parent = @parent.prepend("#{@parent_modules.map(&:name).join('::')}::") if @parent_modules.any?
88 | super
89 | end
90 |
91 | def visit_module_node(node)
92 | @parent_modules << node.constant_path.parent.name if node.constant_path && node.constant_path.respond_to?(:parent)
93 | @parent_modules << node
94 | super
95 | end
96 |
97 | def visit_def_node(node)
98 | @methods << node
99 | super
100 | end
101 |
102 | def visit_interpolated_x_string_node(node)
103 | @interpolated_xstrings << node
104 | super
105 | end
106 |
107 | def visit_local_variable_write_node(node)
108 | @lvars << node
109 | super
110 | end
111 |
112 | def visit_instance_variable_write_node(node)
113 | @ivars << node
114 | super
115 | end
116 | end
117 | end
118 | end
119 |
--------------------------------------------------------------------------------
/test/spektr/targets/base_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class BaseTest < Minitest::Test
4 | def setup_application_controller
5 | code = <<-CODE
6 | class ApplicationController
7 | http_basic_authenticate_with name: "dhh", password: "secret", except: :index
8 |
9 | def index
10 | end
11 |
12 | def show
13 | end
14 |
15 | private
16 | def authenticate!
17 | end
18 | end
19 | CODE
20 | @target = Spektr::Targets::Base.new('application_controller.rb', code)
21 | end
22 |
23 |
24 | def test_it_sets_name
25 | application_controller = <<-CODE
26 | class ApplicationController
27 | protect_from_forgery
28 | end
29 | CODE
30 |
31 | admin_application_controller = <<-CODE
32 | module Admin
33 | class ApplicationController < ApplicationController
34 | end
35 | end
36 | CODE
37 |
38 | admin_controller = <<-CODE
39 | module Admin
40 | class AdminController < Admin::ApplicationController
41 | end
42 | end
43 | CODE
44 |
45 | admin_posts_controller = <<-CODE
46 | module Admin
47 | class PostsController < AdminController
48 | end
49 | end
50 | CODE
51 | application_controller = Spektr::Targets::Controller.new('application_controller.rb', application_controller)
52 | assert_equal "ApplicationController", application_controller.name
53 | admin_application_controller = Spektr::Targets::Controller.new('admin/application_controller.rb', admin_application_controller)
54 | assert_equal "Admin::ApplicationController", admin_application_controller.name
55 | admin_controller = Spektr::Targets::Controller.new('admin_controller.rb', admin_controller)
56 | assert_equal "Admin::AdminController", admin_controller.name
57 | admin_posts_controller = Spektr::Targets::Controller.new('posts_controller.rb', admin_posts_controller)
58 | assert_equal "Admin::PostsController", admin_posts_controller.name
59 | end
60 |
61 | def test_it_finds_call
62 | setup_application_controller
63 | calls = @target.find_calls :http_basic_authenticate_with
64 | refute_empty calls
65 | assert_equal 1, calls.size
66 | end
67 |
68 | def test_it_finds_call_for_receiver
69 | setup_application_controller
70 | code = <<-CODE
71 | Kernel.exec("ls")
72 | POSIX::Spawn.exec("ls")
73 | CODE
74 | target = Spektr::Targets::Base.new('application_controller.rb', code)
75 | assert_equal 1, target.find_calls(:exec, :Kernel).size
76 | assert_equal 1, target.find_calls(:exec, "POSIX::Spawn".to_sym).size
77 |
78 | end
79 |
80 | def test_it_finds_methods
81 | setup_application_controller
82 | assert_equal 3, @target.method_definitions.size
83 | end
84 |
85 | def test_it_finds_public_methods
86 | setup_application_controller
87 | assert_equal 2, @target.public_methods.size
88 | end
89 |
90 | def test_it_finds_call_with_block
91 | setup_application_controller
92 | code = <<-CODE
93 | [1].each do |i|
94 | link_to "inside block", i
95 | end
96 | link_to "test" do
97 | "hey"
98 | end
99 | CODE
100 | target = Spektr::Targets::Base.new('application_controller.rb', code)
101 | assert_equal 1, target.find_calls_with_block(:link_to).size
102 | end
103 |
104 | def test_it_finds_parent
105 | code = <<-CODE
106 | class Model < Parent
107 | end
108 | CODE
109 | target = Spektr::Targets::Base.new('model.rb', code)
110 | assert_equal 'Parent', target.parent
111 | end
112 |
113 | def test_it_finds_namespaced_parent
114 | code = <<-CODE
115 | class Model < Namespace::Parent
116 | end
117 | CODE
118 | target = Spektr::Targets::Base.new('model.rb', code)
119 | assert_equal 'Namespace::Parent', target.parent
120 | end
121 |
122 | def test_it_finds_module_parent
123 | code = <<-CODE
124 | module Namespace
125 | class Model < Parent
126 | end
127 | end
128 | CODE
129 | target = Spektr::Targets::Base.new('namespace/model.rb', code)
130 | assert_equal 'Namespace::Parent', target.parent
131 | end
132 |
133 | def test_it_finds_parent_with_same_name_in_module
134 | code = <<-CODE
135 | class Model < Namespace::Model
136 | def foo
137 | Bar.new
138 | end
139 | end
140 | CODE
141 | target = Spektr::Targets::Base.new('namespace/model.rb', code)
142 | assert_equal 'Namespace::Model', target.parent
143 | end
144 |
145 | def test_it_finds_struct_parent
146 | code = <<-CODE
147 | class Result < Struct.new(:status_code, :message)
148 | end
149 | CODE
150 | target = Spektr::Targets::Base.new('cat.rb', code)
151 | assert_equal 'Struct', target.parent
152 | end
153 | end
154 |
--------------------------------------------------------------------------------
/test/spektr/checks/csrf_setting_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class CsrfSettingTest < Minitest::Test
4 | def test_it_does_not_fail_when_parent_enables_protection
5 | application_controller = <<-CODE
6 | require "foobar"
7 | class ApplicationController
8 | protect_from_forgery
9 | end
10 | CODE
11 | code = <<-CODE
12 | class PostsController < ApplicationController
13 | end
14 | CODE
15 | app = Spektr::App.new(checks: [Spektr::Checks::CsrfSetting])
16 | app.rails_version = Gem::Version.new('4.0.0')
17 | app.controllers = [Spektr::Targets::Controller.new('application_controller.rb', application_controller)]
18 | controller = Spektr::Targets::Controller.new('posts_controller.rb', code)
19 | check = Spektr::Checks::CsrfSetting.new(app, controller)
20 | check.run
21 | assert_equal 0, app.warnings.size
22 | end
23 |
24 | def test_it_doesnot_fail_with_multi_level_parents
25 | application_controller = <<-CODE
26 | class ApplicationController
27 | protect_from_forgery
28 | end
29 | CODE
30 |
31 | admin_controller = <<-CODE
32 | module Admin
33 | class AdminController < ApplicationController
34 | end
35 | end
36 | CODE
37 |
38 | code = <<-CODE
39 | module Admin
40 | class PostsController < AdminController
41 | end
42 | end
43 | CODE
44 | app = Spektr::App.new(checks: [Spektr::Checks::CsrfSetting])
45 | app.rails_version = Gem::Version.new('4.0.0')
46 | app.controllers = [Spektr::Targets::Controller.new('application_controller.rb', application_controller),
47 | # Spektr::Targets::Controller.new('admin/application_controller.rb', admin_application_controller),
48 | Spektr::Targets::Controller.new('admin_controller.rb', admin_controller)]
49 | controller = Spektr::Targets::Controller.new('posts_controller.rb', code)
50 | check = Spektr::Checks::CsrfSetting.new(app, controller)
51 | check.run
52 | assert_equal 0, app.warnings.size
53 |
54 | code = <<-CODE
55 | module Admin
56 | module Settings
57 | class BaseController < Admin::ApplicationController
58 | end
59 | end
60 | end
61 | CODE
62 | app.controllers << Spektr::Targets::Controller.new('admin/settings/base_controller.rb', code)
63 | code = <<-CODE
64 | module Admin
65 | module Settings
66 | class CampaignsController < Admin::Settings::BaseController
67 | end
68 | end
69 | end
70 | CODE
71 | controller = Spektr::Targets::Controller.new('admin/settings/campaigns_controller.rb', code)
72 | app.controllers << controller
73 | check = Spektr::Checks::CsrfSetting.new(app, controller)
74 | check.run
75 | assert_equal 0, app.warnings.size
76 |
77 | generic_controller = <<-CODE
78 | module Admin
79 | class GenericController < ApplicationController
80 | end
81 | end
82 | CODE
83 | code = <<-CODE
84 | module Admin
85 | class PostsController < Admin::GenericController
86 | end
87 | end
88 | CODE
89 | app = Spektr::App.new(checks: [Spektr::Checks::CsrfSetting])
90 | app.rails_version = Gem::Version.new('4.0.0')
91 | app.controllers = [
92 | Spektr::Targets::Controller.new('application_controller.rb', application_controller),
93 | Spektr::Targets::Controller.new('generic_controller.rb', generic_controller)
94 | ]
95 | controller = Spektr::Targets::Controller.new('posts_controller.rb', code)
96 | check = Spektr::Checks::CsrfSetting.new(app, controller)
97 | check.run
98 | assert_equal 0, app.warnings.size
99 | end
100 |
101 | def test_it_fails_when_skips_protection
102 | application_controller = <<-CODE
103 | class ApplicationController
104 | protect_from_forgery
105 | end
106 | CODE
107 | code = <<-CODE
108 | class PostsController < ApplicationController
109 | skip_forgery_protection
110 | end
111 | CODE
112 | app = Spektr::App.new(checks: [Spektr::Checks::CsrfSetting])
113 | app.rails_version = Gem::Version.new('4.0.0')
114 | app.controllers = [Spektr::Targets::Controller.new('application_controller.rb', application_controller)]
115 | controller = Spektr::Targets::Controller.new('posts_controller.rb', code)
116 | check = Spektr::Checks::CsrfSetting.new(app, controller)
117 | check.run
118 | assert_equal 1, app.warnings.size
119 | end
120 |
121 | def test_it_does_not_fails_when_skips_protection_with_only_or_except
122 | application_controller = <<-CODE
123 | class ApplicationController
124 | protect_from_forgery
125 | end
126 | CODE
127 | code = <<-CODE
128 | class PostsController < ApplicationController
129 | skip_forgery_protection only: [:create]
130 | end
131 | CODE
132 | app = Spektr::App.new(checks: [Spektr::Checks::CsrfSetting])
133 | app.rails_version = Gem::Version.new('4.0.0')
134 | app.controllers = [Spektr::Targets::Controller.new('application_controller.rb', application_controller)]
135 | controller = Spektr::Targets::Controller.new('posts_controller.rb', code)
136 | check = Spektr::Checks::CsrfSetting.new(app, controller)
137 | check.run
138 | assert_equal 0, app.warnings.size
139 | end
140 | end
141 |
--------------------------------------------------------------------------------
/lib/spektr/app.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class App
3 | attr_accessor :root, :checks, :initializers, :controllers, :models, :views, :lib_files, :routes, :warnings, :rails_version,
4 | :production_config, :gem_specs, :ruby_version
5 |
6 | def initialize(checks:, ignore: [], root: './')
7 | @root = root
8 | @checks = checks
9 | @controllers = []
10 | @models = []
11 | @warnings = []
12 | @json_output = {
13 | app: {},
14 | advisories: []
15 | }
16 | @ignore = ignore
17 | @ruby_version = '2.7.1'
18 | version_file = File.join(root, '.ruby-version')
19 | @ruby_version = File.read(version_file).lines.first if File.exist?(version_file)
20 | end
21 |
22 | def load
23 | loaded_files = []
24 |
25 | config_path = File.join(@root, 'config', 'environments', 'production.rb')
26 | if File.exist?(config_path)
27 | @production_config = Targets::Config.new(config_path,
28 | File.read(config_path, encoding: 'utf-8'))
29 | end
30 |
31 | @initializers = initializer_paths.map do |path|
32 | loaded_files << path
33 | Targets::Base.new(path, File.read(path))
34 | end
35 | @controllers = controller_paths.map do |path|
36 | loaded_files << path
37 | Targets::Controller.new(path, File.read(path, encoding: 'utf-8'))
38 | end
39 | @models = model_paths.map do |path|
40 | loaded_files << path
41 | Targets::Model.new(path, File.read(path, encoding: 'utf-8'))
42 | end
43 | @views = view_paths.map do |path|
44 | loaded_files << path
45 | Targets::View.new(path, File.read(path, encoding: 'utf-8'))
46 | end
47 | @routes = [File.join(@root, 'config', 'routes.rb')].map do |path|
48 | next unless File.exist? path
49 |
50 | loaded_files << path
51 | Targets::Routes.new(path, File.read(path, encoding: 'utf-8'))
52 | end.reject(&:nil?)
53 | # TODO: load non-app lib too
54 | @lib_files = find_files('lib').map do |path|
55 | next if loaded_files.include?(path)
56 | Targets::Base.new(path, File.read(path, encoding: 'utf-8'))
57 | end.reject(&:nil?)
58 | self
59 | end
60 |
61 | def scan!
62 | @checks.each do |check|
63 | if @controllers
64 | @controllers.each do |controller|
65 | check.new(self, controller).run
66 | end
67 | end
68 | if @views
69 | @views.each do |view|
70 | check.new(self, view).run
71 | end
72 | end
73 | if @models
74 | @models.each do |view|
75 | check.new(self, view).run
76 | end
77 | end
78 | if @routes
79 | @routes.each do |view|
80 | check.new(self, view).run
81 | end
82 | end
83 | if @initializers
84 | @initializers.each do |i|
85 | check.new(self, i).run
86 | end
87 | end
88 | if @lib_files
89 | @lib_files.each do |i|
90 | check.new(self, i).run
91 | end
92 | end
93 |
94 | check.new(self, @production_config).run if @production_config
95 | end
96 | self
97 | end
98 |
99 | def report
100 | @json_output[:app][:rails_version] = @rails_version
101 | @json_output[:app][:initializers] = @initializers.size
102 | @json_output[:app][:controllers] = @controllers.size
103 | @json_output[:app][:models] = @models.size
104 | @json_output[:app][:views] = @views.size
105 | @json_output[:app][:routes] = @routes.size
106 | @json_output[:app][:lib_files] = @lib_files.size
107 |
108 | @warnings.each do |warning|
109 | next if @ignore.include?(warning.fingerprint)
110 |
111 | @json_output[:advisories] << {
112 | name: warning.check.name,
113 | description: warning.message,
114 | path: warning.path,
115 | location: warning.location&.start_line,
116 | line: warning.line,
117 | check: warning.check.class.name,
118 | fingerprint: warning.fingerprint
119 | }
120 | end
121 |
122 | @json_output[:summary] = []
123 | @json_output[:checks] = @checks.collect(&:name)
124 |
125 | @json_output[:advisories].group_by { |a| a[:name] }.each do |n, i|
126 | @json_output[:summary] << {
127 | n => i.size
128 | }
129 | end
130 | @json_output
131 | end
132 |
133 | def initializer_paths
134 | @initializer_paths ||= find_files('config/initializers')
135 | end
136 |
137 | def controller_paths
138 | @controller_paths ||= find_files('app/**/controllers')
139 | end
140 |
141 | def model_paths
142 | @model_paths ||= find_files('app/**/models')
143 | end
144 |
145 | def view_paths
146 | @view_paths ||= find_files('app', "{#{%w[html.erb html.haml rhtml js.erb html.slim].join(',')}}")
147 | end
148 |
149 | def find_files(path, extensions = 'rb')
150 | Dir.glob(File.join(@root, path, '**', "*.#{extensions}"))
151 | end
152 |
153 | def gem_specs
154 | return unless File.exist? "#{@root}/Gemfile.lock"
155 |
156 | @gem_specs ||= Bundler::LockfileParser.new(Bundler.read_file("#{@root}/Gemfile.lock")).specs
157 | end
158 |
159 | def has_gem?(name)
160 | return false unless gem_specs
161 |
162 | gem_specs.any? { |spec| spec.name == name }
163 | end
164 |
165 | def rails_version
166 | return unless gem_specs
167 |
168 | @rails_version ||= Gem::Version.new(gem_specs.find { |spec| spec.name == 'rails' }&.version)
169 | end
170 | end
171 | end
172 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/config/environments/production.rb:
--------------------------------------------------------------------------------
1 | require "active_support/core_ext/integer/time"
2 |
3 | Rails.application.configure do
4 | # Settings specified here will take precedence over those in config/application.rb.
5 |
6 | # Code is not reloaded between requests.
7 | config.cache_classes = true
8 |
9 | # Eager load code on boot. This eager loads most of Rails and
10 | # your application in memory, allowing both threaded web servers
11 | # and those relying on copy on write to perform better.
12 | # Rake tasks automatically ignore this option for performance.
13 | config.eager_load = true
14 |
15 | # Full error reports are disabled and caching is turned on.
16 | config.consider_all_requests_local = false
17 | config.action_controller.perform_caching = true
18 |
19 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"]
20 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files).
21 | # config.require_master_key = true
22 |
23 | # Disable serving static files from the `/public` folder by default since
24 | # Apache or NGINX already handles this.
25 | config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present?
26 |
27 | # Compress CSS using a preprocessor.
28 | # config.assets.css_compressor = :sass
29 |
30 | # Do not fallback to assets pipeline if a precompiled asset is missed.
31 | config.assets.compile = false
32 |
33 | # Enable serving of images, stylesheets, and JavaScripts from an asset server.
34 | # config.asset_host = 'http://assets.example.com'
35 |
36 | # Specifies the header that your server uses for sending files.
37 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache
38 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX
39 |
40 | # Store uploaded files on the local file system (see config/storage.yml for options).
41 | config.active_storage.service = :local
42 |
43 | # Mount Action Cable outside main process or domain.
44 | # config.action_cable.mount_path = nil
45 | # config.action_cable.url = 'wss://example.com/cable'
46 | # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ]
47 |
48 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
49 | # config.force_ssl = true
50 |
51 | # Include generic and useful information about system operation, but avoid logging too much
52 | # information to avoid inadvertent exposure of personally identifiable information (PII).
53 | config.log_level = :info
54 |
55 | # Prepend all log lines with the following tags.
56 | config.log_tags = [ :request_id ]
57 |
58 | # Use a different cache store in production.
59 | # config.cache_store = :mem_cache_store
60 |
61 | # Use a real queuing backend for Active Job (and separate queues per environment).
62 | # config.active_job.queue_adapter = :resque
63 | # config.active_job.queue_name_prefix = "rails6_1_production"
64 |
65 | config.action_mailer.perform_caching = false
66 |
67 | # Ignore bad email addresses and do not raise email delivery errors.
68 | # Set this to true and configure the email server for immediate delivery to raise delivery errors.
69 | # config.action_mailer.raise_delivery_errors = false
70 |
71 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
72 | # the I18n.default_locale when a translation cannot be found).
73 | config.i18n.fallbacks = true
74 |
75 | # Send deprecation notices to registered listeners.
76 | config.active_support.deprecation = :notify
77 |
78 | # Log disallowed deprecations.
79 | config.active_support.disallowed_deprecation = :log
80 |
81 | # Tell Active Support which deprecation messages to disallow.
82 | config.active_support.disallowed_deprecation_warnings = []
83 |
84 | # Use default logging formatter so that PID and timestamp are not suppressed.
85 | config.log_formatter = ::Logger::Formatter.new
86 |
87 | # Use a different logger for distributed setups.
88 | # require "syslog/logger"
89 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name')
90 |
91 | if ENV["RAILS_LOG_TO_STDOUT"].present?
92 | logger = ActiveSupport::Logger.new(STDOUT)
93 | logger.formatter = config.log_formatter
94 | config.logger = ActiveSupport::TaggedLogging.new(logger)
95 | end
96 |
97 | # Do not dump schema after migrations.
98 | config.active_record.dump_schema_after_migration = false
99 |
100 | # Inserts middleware to perform automatic connection switching.
101 | # The `database_selector` hash is used to pass options to the DatabaseSelector
102 | # middleware. The `delay` is used to determine how long to wait after a write
103 | # to send a subsequent read to the primary.
104 | #
105 | # The `database_resolver` class is used by the middleware to determine which
106 | # database is appropriate to use based on the time delay.
107 | #
108 | # The `database_resolver_context` class is used by the middleware to set
109 | # timestamps for the last write to the primary. The resolver uses the context
110 | # class timestamps to determine how long to wait before reading from the
111 | # replica.
112 | #
113 | # By default Rails will store a last write timestamp in the session. The
114 | # DatabaseSelector middleware is designed as such you can define your own
115 | # strategy for connection switching and pass that into the middleware through
116 | # these configuration options.
117 | # config.active_record.database_selector = { delay: 2.seconds }
118 | # config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver
119 | # config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session
120 | end
121 |
--------------------------------------------------------------------------------
/test/spektr/targets/controller_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class ControllerTest < Minitest::Test
4 | def test_it_sets_name
5 | code = <<-CODE
6 | require "foobar"
7 | class ApplicationController
8 | end
9 | CODE
10 | controller = Spektr::Targets::Controller.new('application_controller.rb', code)
11 | assert_equal 'ApplicationController', controller.name
12 | code = <<-CODE
13 | module Admin
14 | module Schools
15 | class PupilsController
16 | end
17 | end
18 | end
19 | CODE
20 | controller = Spektr::Targets::Controller.new('pupils_controller.rb', code)
21 | assert_equal 'Admin::Schools::PupilsController', controller.name
22 | code = <<-CODE
23 | module Admin::Schools
24 | class PupilsController
25 | def index
26 | end
27 | end
28 | end
29 | CODE
30 | Spektr::Targets::Controller.new('pupils_controller.rb', code)
31 | assert_equal 'Admin::Schools::PupilsController', controller.name
32 | code = <<-CODE
33 | module Admin
34 | class Schools::PupilsController
35 | def index
36 | end
37 | end
38 | end
39 | CODE
40 | controller = Spektr::Targets::Controller.new('pupils_controller.rb', code)
41 | assert_equal 'Admin::PupilsController', controller.name
42 | end
43 |
44 | def test_it_sets_parent
45 | code = <<-CODE
46 | class ApplicationController
47 | end
48 | CODE
49 | controller = Spektr::Targets::Controller.new('application_controller.rb', code)
50 | assert_equal "", controller.parent
51 |
52 | code = <<-CODE
53 | class PostsController < ApplicationController
54 | end
55 | CODE
56 | controller = Spektr::Targets::Controller.new('application_controller.rb', code)
57 | assert_equal 'ApplicationController', controller.parent
58 |
59 | code = <<-CODE
60 | class SessionsController < Devise::SessionsController
61 | end
62 | CODE
63 | controller = Spektr::Targets::Controller.new('application_controller.rb', code)
64 | assert_equal 'Devise::SessionsController', controller.parent
65 |
66 | code = <<-CODE
67 | module Admin
68 | class PostsController < ApplicationController
69 | def index
70 | end
71 | end
72 | end
73 | CODE
74 | controller = Spektr::Targets::Controller.new('admin/posts_controller.rb', code)
75 | assert_equal 'Admin::ApplicationController', controller.parent
76 |
77 | code = <<-CODE
78 | module Admin
79 | class PostsController < ApplicationController
80 | def index
81 | end
82 | end
83 | end
84 | CODE
85 | controller = Spektr::Targets::Controller.new('admin/posts_controller.rb', code)
86 | assert_equal 'Admin::ApplicationController', controller.parent
87 |
88 | code = <<-CODE
89 | module Admin
90 | module Settings
91 | class CampaignsController < BaseController
92 | end
93 | end
94 | end
95 | CODE
96 | controller = Spektr::Targets::Controller.new('admin/settings/campaigns_controller.rb', code)
97 | assert_equal 'Admin::Settings::BaseController', controller.parent
98 |
99 | code = <<-CODE
100 | module Api
101 | module V0
102 | module Admin
103 | class OrganizationsController < ApiController
104 | end
105 | end
106 | end
107 | end
108 | CODE
109 | controller = Spektr::Targets::Controller.new('api/v0/admin/organisations_controller.rb', code)
110 | assert_equal 'Api::V0::Admin::ApiController', controller.parent
111 |
112 | code = <<-CODE
113 | module Admin
114 | class ProfileFieldsController < ApplicationController
115 | end
116 | end
117 | CODE
118 | controller = Spektr::Targets::Controller.new('admin/profile_controller.rb', code)
119 | assert_equal 'Admin::ApplicationController', controller.parent
120 | end
121 |
122 | def test_it_sets_template
123 | code = <<-CODE
124 | class PostsController
125 | def index
126 | end
127 | end
128 | CODE
129 | controller = Spektr::Targets::Controller.new('application_controller.rb', code)
130 | assert_equal 'posts/index', controller.actions.first.template
131 | code = <<-CODE
132 | module Admin
133 | class PostsController < ApplicationController
134 | def index
135 | end
136 | end
137 | end
138 | CODE
139 | controller = Spektr::Targets::Controller.new('application_controller.rb', code)
140 | assert_equal 'admin/posts/index', controller.actions.first.template
141 | code = <<-CODE
142 | class Admin::PostsController < ApplicationController
143 | def index
144 | end
145 | end
146 | CODE
147 | controller = Spektr::Targets::Controller.new('application_controller.rb', code)
148 | assert_equal 'admin/posts/index', controller.actions.first.template
149 | code = <<-CODE
150 | class SessionsController < Devise::SessionsController
151 | def index
152 | end
153 | end
154 | CODE
155 | controller = Spektr::Targets::Controller.new('application_controller.rb', code)
156 | assert_equal 'devise/sessions/index', controller.actions.first.template
157 | end
158 |
159 | def setup_application_controller
160 | code = <<-CODE
161 | class WelcomeController < ApplicationController
162 | http_basic_authenticate_with name: "dhh", password: "secret", except: :index
163 |
164 | def index
165 | end
166 |
167 | def show
168 | end
169 |
170 | def update
171 | render :edit
172 | end
173 |
174 | private
175 | def authenticate!
176 | end
177 | end
178 | CODE
179 |
180 | @controller = Spektr::Targets::Controller.new('application_controller.rb', code)
181 | end
182 |
183 | def test_it_finds_call
184 | setup_application_controller
185 | calls = @controller.find_calls :http_basic_authenticate_with
186 | refute_empty calls
187 | assert_equal 1, calls.size
188 | end
189 |
190 | def test_it_registers_actions
191 | setup_application_controller
192 | assert_equal 3, @controller.actions.size
193 | assert_nil @controller.actions.first.body
194 | refute_nil @controller.actions[2].body
195 | end
196 | end
197 |
--------------------------------------------------------------------------------
/lib/spektr/checks/base.rb:
--------------------------------------------------------------------------------
1 | module Spektr
2 | class Checks::Base
3 | attr_accessor :name
4 |
5 | def initialize(app, target)
6 | @app = app
7 | @target = target
8 | @targets = []
9 | end
10 |
11 | def run
12 | ::Spektr.logger.debug "Running #{self.class.name} on #{@target.path}"
13 | target_affected? && should_run?
14 | end
15 |
16 | def target_affected?
17 | @targets.include?(@target.class.name)
18 | end
19 |
20 | def should_run?
21 | if version_affected && @app.rails_version
22 | version_affected > @app.rails_version
23 | else
24 | true
25 | end
26 | end
27 |
28 | def warn!(target, check, location, message, confidence = :high)
29 | full_path = target.is_a?(String) ? target : target.path
30 | path = full_path.gsub(@app.root, "")
31 | return if dupe?(path, location, message)
32 |
33 | @app.warnings << Warning.new(path, full_path, check, location, message, confidence)
34 | end
35 |
36 | def dupe?(path, location, message)
37 | @app.warnings.find do |w|
38 | w.path == path &&
39 | (w.location.nil? || w.location&.start_line == location&.start_line) &&
40 | w.message == message
41 | end
42 | end
43 |
44 | def version_affected; end
45 |
46 | def user_input?(node)
47 | return false if node.nil?
48 | case node.type
49 | when :call_node
50 | return true if %i[params cookies request].include? node.name
51 | return true if node.receiver && user_input?(node.receiver)
52 | if node.arguments
53 | node.arguments.arguments.each do |argument|
54 | return true if user_input?(argument)
55 | end
56 | end
57 | when :embedded_statements_node
58 | node.statements.body.each do |item|
59 | return true if user_input? item
60 | end
61 | when :interpolated_string_node, :interpolated_x_string_node
62 | node.parts.each do |part|
63 | return true if user_input?(part)
64 | end
65 | when :keyword_hash_node, :hash_node
66 | node.elements.each do |element|
67 | return true if user_input?(element.key)
68 | return true if user_input?(element.value)
69 | end
70 | # TODO: make this better. ivars can be overridden in the view as well and
71 | # can be set in non controller targets too
72 | when :instance_variable_read_node
73 | return false unless @target.respond_to?(:view_path)
74 | actions = []
75 | @app.controllers.each do |controller|
76 | actions = actions.concat controller.actions.select { |action|
77 | action.template == @target.view_path
78 | }
79 | end
80 | actions.each do |action|
81 | next unless action.body
82 | action.body.each do |exp|
83 | return true if exp.name == node.name && user_input?(exp)
84 | end
85 | end
86 | when :local_variable_read_node
87 | return user_input?(@target.lvars.find{|n| n.name == node.name })
88 | when :instance_variable_write_node, :local_variable_write_node
89 | return user_input? node.value
90 | when :parentheses_node
91 | node.body.body.each do |item|
92 | return user_input? item
93 | end
94 |
95 | when :string_node, :symbol_node, :constant_read_node, :integer_node, :true_node, :constant_path_node
96 | # do nothing
97 | else
98 | raise "Unknown argument type #{node.type.inspect} #{node.inspect}"
99 | end
100 | false
101 | end
102 |
103 | # TODO: this doesn't work properly
104 | def model_attribute?(node)
105 | model_names = @app.models.collect(&:name)
106 | case node.type
107 | when :local_variable_read_node, :instance_variable_read_node
108 | # TODO: handle helpers here too
109 | if ["Spektr::Targets::Controller", "Spektr::Targets::View"].include?(@target.class.name)
110 | actions = []
111 | @app.controllers.each do |controller|
112 | actions = actions.concat controller.actions.select { |action|
113 | action.template == @target.view_path if @target.respond_to? :view_path
114 | }
115 | end
116 | actions.each do |action|
117 | action.body.each do |exp|
118 | next unless node.respond_to?(:name)
119 | return model_attribute?(exp.value) if exp.is_a?(Prism::InstanceVariableWriteNode) && exp.name == node.name
120 | end
121 | end
122 | end
123 | when :call_node
124 | return model_attribute?(node.receiver) if node.receiver
125 | if node.arguments
126 | node.arguments.arguments.each do |argument|
127 | return true if model_attribute?(argument)
128 | end
129 | end
130 | when :parentheses_node
131 | node.body.body.each do |item|
132 | return model_attribute? item
133 | end
134 | when :constant_read_node
135 | return true if model_names.include? node.name.to_s
136 | when :interpolated_string_node
137 | node.parts.each do |item|
138 | return model_attribute? item
139 | end
140 | when :string_node, :symbol_node, :integer_node, :constant_path_node
141 | # do nothing
142 | else
143 | raise "Unknown argument type #{node.type}"
144 | end
145 | end
146 |
147 | def app_version_between?(a, b)
148 | version_between?(a, b, @app.rails_version)
149 | end
150 |
151 | def version_between?(a, b, version)
152 | version = Gem::Version.new(version) unless version.is_a? Gem::Version
153 | version >= Gem::Version.new(a) && version <= Gem::Version.new(b)
154 | end
155 |
156 | def receivers_for(node)
157 | receivers = []
158 | receiver = node.receiver
159 | while receiver
160 | receivers << receiver.name
161 | receiver = receiver.respond_to?(:receiver) ? receiver.receiver : false
162 | end
163 | receivers
164 | end
165 |
166 | def full_receiver(node)
167 | parents = []
168 | parent = node.receiver.parent if node.receiver.respond_to?(:parent)
169 | while parent
170 | parents << parent.name
171 | parent = parent.respond_to?(:parent) ? parent.parent : false
172 | end
173 | parents.reverse.concat(receivers_for(node).reverse).join(".")
174 | end
175 | end
176 | end
177 |
--------------------------------------------------------------------------------
/test/apps/rails6.1/Gemfile.lock:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | actioncable (6.1.4)
5 | actionpack (= 6.1.4)
6 | activesupport (= 6.1.4)
7 | nio4r (~> 2.0)
8 | websocket-driver (>= 0.6.1)
9 | actionmailbox (6.1.4)
10 | actionpack (= 6.1.4)
11 | activejob (= 6.1.4)
12 | activerecord (= 6.1.4)
13 | activestorage (= 6.1.4)
14 | activesupport (= 6.1.4)
15 | mail (>= 2.7.1)
16 | actionmailer (6.1.4)
17 | actionpack (= 6.1.4)
18 | actionview (= 6.1.4)
19 | activejob (= 6.1.4)
20 | activesupport (= 6.1.4)
21 | mail (~> 2.5, >= 2.5.4)
22 | rails-dom-testing (~> 2.0)
23 | actionpack (6.1.4)
24 | actionview (= 6.1.4)
25 | activesupport (= 6.1.4)
26 | rack (~> 2.0, >= 2.0.9)
27 | rack-test (>= 0.6.3)
28 | rails-dom-testing (~> 2.0)
29 | rails-html-sanitizer (~> 1.0, >= 1.2.0)
30 | actiontext (6.1.4)
31 | actionpack (= 6.1.4)
32 | activerecord (= 6.1.4)
33 | activestorage (= 6.1.4)
34 | activesupport (= 6.1.4)
35 | nokogiri (>= 1.8.5)
36 | actionview (6.1.4)
37 | activesupport (= 6.1.4)
38 | builder (~> 3.1)
39 | erubi (~> 1.4)
40 | rails-dom-testing (~> 2.0)
41 | rails-html-sanitizer (~> 1.1, >= 1.2.0)
42 | activejob (6.1.4)
43 | activesupport (= 6.1.4)
44 | globalid (>= 0.3.6)
45 | activemodel (6.1.4)
46 | activesupport (= 6.1.4)
47 | activerecord (6.1.4)
48 | activemodel (= 6.1.4)
49 | activesupport (= 6.1.4)
50 | activestorage (6.1.4)
51 | actionpack (= 6.1.4)
52 | activejob (= 6.1.4)
53 | activerecord (= 6.1.4)
54 | activesupport (= 6.1.4)
55 | marcel (~> 1.0.0)
56 | mini_mime (>= 1.1.0)
57 | activesupport (6.1.4)
58 | concurrent-ruby (~> 1.0, >= 1.0.2)
59 | i18n (>= 1.6, < 2)
60 | minitest (>= 5.1)
61 | tzinfo (~> 2.0)
62 | zeitwerk (~> 2.3)
63 | addressable (2.8.0)
64 | public_suffix (>= 2.0.2, < 5.0)
65 | bindex (0.8.1)
66 | bootsnap (1.7.5)
67 | msgpack (~> 1.0)
68 | brakeman (5.1.1)
69 | builder (3.2.4)
70 | byebug (11.1.3)
71 | capybara (3.35.3)
72 | addressable
73 | mini_mime (>= 0.1.3)
74 | nokogiri (~> 1.8)
75 | rack (>= 1.6.0)
76 | rack-test (>= 0.6.3)
77 | regexp_parser (>= 1.5, < 3.0)
78 | xpath (~> 3.2)
79 | childprocess (3.0.0)
80 | concurrent-ruby (1.1.9)
81 | crass (1.0.6)
82 | erubi (1.10.0)
83 | ffi (1.15.3)
84 | globalid (0.4.2)
85 | activesupport (>= 4.2.0)
86 | i18n (1.8.10)
87 | concurrent-ruby (~> 1.0)
88 | jbuilder (2.11.2)
89 | activesupport (>= 5.0.0)
90 | listen (3.5.1)
91 | rb-fsevent (~> 0.10, >= 0.10.3)
92 | rb-inotify (~> 0.9, >= 0.9.10)
93 | loofah (2.10.0)
94 | crass (~> 1.0.2)
95 | nokogiri (>= 1.5.9)
96 | mail (2.7.1)
97 | mini_mime (>= 0.1.1)
98 | marcel (1.0.1)
99 | method_source (1.0.0)
100 | mini_mime (1.1.0)
101 | minitest (5.14.4)
102 | msgpack (1.4.2)
103 | nio4r (2.5.7)
104 | nokogiri (1.11.7-x86_64-darwin)
105 | racc (~> 1.4)
106 | public_suffix (4.0.6)
107 | puma (5.3.2)
108 | nio4r (~> 2.0)
109 | racc (1.5.2)
110 | rack (2.2.3)
111 | rack-mini-profiler (2.3.2)
112 | rack (>= 1.2.0)
113 | rack-proxy (0.7.0)
114 | rack
115 | rack-test (1.1.0)
116 | rack (>= 1.0, < 3)
117 | rails (6.1.4)
118 | actioncable (= 6.1.4)
119 | actionmailbox (= 6.1.4)
120 | actionmailer (= 6.1.4)
121 | actionpack (= 6.1.4)
122 | actiontext (= 6.1.4)
123 | actionview (= 6.1.4)
124 | activejob (= 6.1.4)
125 | activemodel (= 6.1.4)
126 | activerecord (= 6.1.4)
127 | activestorage (= 6.1.4)
128 | activesupport (= 6.1.4)
129 | bundler (>= 1.15.0)
130 | railties (= 6.1.4)
131 | sprockets-rails (>= 2.0.0)
132 | rails-dom-testing (2.0.3)
133 | activesupport (>= 4.2.0)
134 | nokogiri (>= 1.6)
135 | rails-html-sanitizer (1.3.0)
136 | loofah (~> 2.3)
137 | railties (6.1.4)
138 | actionpack (= 6.1.4)
139 | activesupport (= 6.1.4)
140 | method_source
141 | rake (>= 0.13)
142 | thor (~> 1.0)
143 | rake (13.0.6)
144 | rb-fsevent (0.11.0)
145 | rb-inotify (0.10.1)
146 | ffi (~> 1.0)
147 | regexp_parser (2.1.1)
148 | rubyzip (2.3.2)
149 | sass-rails (6.0.0)
150 | sassc-rails (~> 2.1, >= 2.1.1)
151 | sassc (2.4.0)
152 | ffi (~> 1.9)
153 | sassc-rails (2.1.2)
154 | railties (>= 4.0.0)
155 | sassc (>= 2.0)
156 | sprockets (> 3.0)
157 | sprockets-rails
158 | tilt
159 | selenium-webdriver (3.142.7)
160 | childprocess (>= 0.5, < 4.0)
161 | rubyzip (>= 1.2.2)
162 | semantic_range (3.0.0)
163 | spring (2.1.1)
164 | sprockets (4.0.2)
165 | concurrent-ruby (~> 1.0)
166 | rack (> 1, < 3)
167 | sprockets-rails (3.2.2)
168 | actionpack (>= 4.0)
169 | activesupport (>= 4.0)
170 | sprockets (>= 3.0.0)
171 | sqlite3 (1.4.2)
172 | thor (1.1.0)
173 | tilt (2.0.10)
174 | turbolinks (5.2.1)
175 | turbolinks-source (~> 5.2)
176 | turbolinks-source (5.2.0)
177 | tzinfo (2.0.4)
178 | concurrent-ruby (~> 1.0)
179 | web-console (4.1.0)
180 | actionview (>= 6.0.0)
181 | activemodel (>= 6.0.0)
182 | bindex (>= 0.4.0)
183 | railties (>= 6.0.0)
184 | webdrivers (4.6.0)
185 | nokogiri (~> 1.6)
186 | rubyzip (>= 1.3.0)
187 | selenium-webdriver (>= 3.0, < 4.0)
188 | webpacker (5.4.0)
189 | activesupport (>= 5.2)
190 | rack-proxy (>= 0.6.1)
191 | railties (>= 5.2)
192 | semantic_range (>= 2.3.0)
193 | websocket-driver (0.7.5)
194 | websocket-extensions (>= 0.1.0)
195 | websocket-extensions (0.1.5)
196 | xpath (3.2.0)
197 | nokogiri (~> 1.8)
198 | zeitwerk (2.4.2)
199 |
200 | PLATFORMS
201 | ruby
202 |
203 | DEPENDENCIES
204 | bootsnap (>= 1.4.4)
205 | brakeman
206 | byebug
207 | capybara (>= 3.26)
208 | jbuilder (~> 2.7)
209 | listen (~> 3.3)
210 | puma (~> 5.0)
211 | rack-mini-profiler (~> 2.0)
212 | rails (~> 6.1.4)
213 | sass-rails (>= 6)
214 | selenium-webdriver
215 | spring
216 | sqlite3 (~> 1.4)
217 | turbolinks (~> 5)
218 | tzinfo-data
219 | web-console (>= 4.1.0)
220 | webdrivers
221 | webpacker (~> 5.0)
222 |
223 | RUBY VERSION
224 | ruby 2.7.2p137
225 |
226 | BUNDLED WITH
227 | 2.1.4
228 |
--------------------------------------------------------------------------------