diff --git a/lib/aikido/zen/attack_wave.rb b/lib/aikido/zen/attack_wave.rb index ab3ac6ad..640397c4 100644 --- a/lib/aikido/zen/attack_wave.rb +++ b/lib/aikido/zen/attack_wave.rb @@ -21,14 +21,14 @@ def initialize(config: Aikido::Zen.config, clock: nil) end end - def attack_wave?(context) + def attack_wave?(context, status_code = nil) client_ip = context.request.client_ip return false unless client_ip return false if @event_times[client_ip] - return false unless AttackWave::Helpers.web_scanner?(context) + return false unless AttackWave::Helpers.web_scanner?(context, status_code) request_count = @request_counts[client_ip] += 1 diff --git a/lib/aikido/zen/attack_wave/helpers.rb b/lib/aikido/zen/attack_wave/helpers.rb index 91e5aba5..5e5b0a6a 100644 --- a/lib/aikido/zen/attack_wave/helpers.rb +++ b/lib/aikido/zen/attack_wave/helpers.rb @@ -1,27 +1,29 @@ # frozen_string_literal: true +require "set" + module Aikido::Zen module AttackWave module Helpers - def self.web_scanner?(context) - return true if suspicious_request?(context) + def self.web_scanner?(context, status_code) + return true if suspicious_request?(context, status_code) return true if include_suspicious_payload?(context) false end - def self.suspicious_request?(context) + def self.suspicious_request?(context, status_code) request = context.request - suspicious_method?(request.request_method) || suspicious_path?(request.path_info) + suspicious_method?(request.request_method) || suspicious_path?(request.path_info, status_code) end def self.suspicious_method?(method) SUSPICIOUS_METHODS.include?(method.downcase) end - def self.suspicious_path?(path) + def self.suspicious_path?(path, status_code) path_parts = path.downcase.split("/") file_name = path_parts.pop if path_parts.length > 0 @@ -34,6 +36,8 @@ def self.suspicious_path?(path) file_extension = file_name_parts.pop if file_name_parts.length > 1 return true if SUSPICIOUS_FILE_EXTENSIONS.include?(file_extension) + + return true if FOREIGN_EXTENSIONS.include?(file_extension) && status_code == 404 end path_parts.any? do |directory_name| @@ -434,6 +438,11 @@ def self.include_suspicious_payload?(context) "sqlite3db" ].map(&:downcase).freeze + # Extensions that a Ruby app would not natively serve. Requests to these + # paths are only treated as scan hits when the response is 404 — a 200 + # may indicate the app is proxying to a PHP/Java backend. + FOREIGN_EXTENSIONS = Set.new(%w[php php3 php4 php5 phtml java jsp jspx]).freeze + SUSPICIOUS_SQL_KEYWORDS = [ "SELECT (CASE WHEN", "SELECT COUNT(", diff --git a/lib/aikido/zen/middleware/attack_wave_protector.rb b/lib/aikido/zen/middleware/attack_wave_protector.rb index 35f3a27c..1c416aad 100644 --- a/lib/aikido/zen/middleware/attack_wave_protector.rb +++ b/lib/aikido/zen/middleware/attack_wave_protector.rb @@ -12,28 +12,29 @@ def initialize(app, zen: Aikido::Zen, settings: Aikido::Zen.runtime_settings) def call(env) response = @app.call(env) + status_code = response[0].to_i context = @zen.current_context - protect(context) + protect(context, status_code) response end # @api private # Visible for testing. - def attack_wave?(context) + def attack_wave?(context, status_code = nil) request = context.request return false if request.nil? return false if @settings.bypassed_ips.include?(request.client_ip) - @zen.attack_wave_detector.attack_wave?(context) + @zen.attack_wave_detector.attack_wave?(context, status_code) end # @api private # Visible for testing. - def protect(context) - if attack_wave?(context) + def protect(context, status_code = nil) + if attack_wave?(context, status_code) client_ip = context.request.client_ip request = Aikido::Zen::AttackWave::Request.new( diff --git a/test/aikido/zen/attack_wave_test.rb b/test/aikido/zen/attack_wave_test.rb index 6fca63ea..8c194c44 100644 --- a/test/aikido/zen/attack_wave_test.rb +++ b/test/aikido/zen/attack_wave_test.rb @@ -3,6 +3,38 @@ require "test_helper" class Aikido::Zen::AttackWaveTest < ActiveSupport::TestCase + class HelpersTest < ActiveSupport::TestCase + test "suspicious_path? returns true for foreign extension with 404 status" do + assert Aikido::Zen::AttackWave::Helpers.suspicious_path?("/admin.php", 404) + assert Aikido::Zen::AttackWave::Helpers.suspicious_path?("/app.jsp", 404) + assert Aikido::Zen::AttackWave::Helpers.suspicious_path?("/page.jspx", 404) + assert Aikido::Zen::AttackWave::Helpers.suspicious_path?("/index.php3", 404) + assert Aikido::Zen::AttackWave::Helpers.suspicious_path?("/index.php4", 404) + assert Aikido::Zen::AttackWave::Helpers.suspicious_path?("/index.php5", 404) + assert Aikido::Zen::AttackWave::Helpers.suspicious_path?("/index.phtml", 404) + assert Aikido::Zen::AttackWave::Helpers.suspicious_path?("/Hello.java", 404) + end + + test "suspicious_path? returns false for foreign extension with non-404 status" do + refute Aikido::Zen::AttackWave::Helpers.suspicious_path?("/admin.php", 200) + refute Aikido::Zen::AttackWave::Helpers.suspicious_path?("/app.jsp", 200) + refute Aikido::Zen::AttackWave::Helpers.suspicious_path?("/admin.php", 301) + refute Aikido::Zen::AttackWave::Helpers.suspicious_path?("/admin.php", 500) + refute Aikido::Zen::AttackWave::Helpers.suspicious_path?("/admin.php", nil) + end + + test "suspicious_path? still returns true for always-suspicious extensions regardless of status" do + assert Aikido::Zen::AttackWave::Helpers.suspicious_path?("/backup.sql", 200) + assert Aikido::Zen::AttackWave::Helpers.suspicious_path?("/data.db", 200) + assert Aikido::Zen::AttackWave::Helpers.suspicious_path?("/dump.bak", 200) + end + + test "suspicious_path? still returns true for suspicious file names regardless of status" do + assert Aikido::Zen::AttackWave::Helpers.suspicious_path?("/.gitconfig", 200) + assert Aikido::Zen::AttackWave::Helpers.suspicious_path?("/wp-config.php", 200) + end + end + class TestClock attr_reader :at