├── 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 | [![Ruby CI](https://github.com/gregmolnar/spektr/actions/workflows/ci.yaml/badge.svg?branch=master)](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 | ![Railgoat example](https://github.com/gregmolnar/spektr/blob/master/railsgoat-example.png) 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 | --------------------------------------------------------------------------------