From 103a23a8ccbfb547a7a226e83de624368b6327b7 Mon Sep 17 00:00:00 2001 From: Christophe De La Fuente Date: Thu, 4 Jun 2026 16:52:21 +0200 Subject: [PATCH] Reporting refactor - first iteration - Add Errors, Reporter & Results - Add in_memory_backend, reporting test_helper & specs - Add db manager backend, connection pool & update reporter - Add Mdm::ModuleExecution lifecycle hooks - Add ModuleExecutionError capture - Update Gemfile to bring metasploit_data_models updates --- Gemfile | 2 + Gemfile.lock | 33 +- db/schema.rb | 65 ++- lib/msf.rb | 1 + lib/msf/base/simple/auxiliary.rb | 207 +++++--- lib/msf/base/simple/evasion.rb | 46 +- lib/msf/base/simple/exploit.rb | 132 +++++- lib/msf/base/simple/post.rb | 52 +- lib/msf/core/auxiliary.rb | 5 +- lib/msf/core/auxiliary/scanner.rb | 24 +- lib/msf/core/evasion.rb | 5 +- lib/msf/core/exploit.rb | 18 +- lib/msf/core/exploit/remote/auto_check.rb | 62 ++- lib/msf/core/exploit_driver.rb | 6 +- lib/msf/core/module.rb | 5 +- lib/msf/core/payload_generator.rb | 68 ++- lib/msf/core/post.rb | 5 +- .../reporting/backends/db_manager_backend.rb | 73 +++ .../reporting/backends/in_memory_backend.rb | 150 ++++++ lib/msf/core/reporting/connection_pool.rb | 47 ++ lib/msf/core/reporting/current_execution.rb | 72 +++ lib/msf/core/reporting/errors.rb | 102 ++++ lib/msf/core/reporting/execution.rb | 448 ++++++++++++++++++ lib/msf/core/reporting/reporter.rb | 135 ++++++ lib/msf/core/reporting/results.rb | 145 ++++++ lib/msf_autoload.rb | 3 +- .../base/simple/auxiliary_lifecycle_spec.rb | 152 ++++++ .../msf/base/simple/exploit_lifecycle_spec.rb | 187 ++++++++ .../msf/base/simple/post_lifecycle_spec.rb | 77 +++ .../scanner_execution_propagation_spec.rb | 45 ++ .../exploit/remote/auto_check_phase_spec.rb | 155 ++++++ .../msf/core/module/fail_with_capture_spec.rb | 170 +++++++ .../module/handle_exception_capture_spec.rb | 185 ++++++++ .../core/payload_generator_lifecycle_spec.rb | 85 ++++ .../payload_generator_payload_wrap_spec.rb | 44 ++ .../backends/db_manager_backend_spec.rb | 94 ++++ .../backends/in_memory_backend_spec.rb | 131 +++++ .../core/reporting/connection_pool_spec.rb | 101 ++++ .../core/reporting/current_execution_spec.rb | 100 ++++ spec/lib/msf/core/reporting/errors_spec.rb | 68 +++ .../reporting/execution_ivar_helpers_spec.rb | 139 ++++++ .../core/reporting/execution_phase_spec.rb | 140 ++++++ .../reporting/execution_record_error_spec.rb | 215 +++++++++ spec/lib/msf/core/reporting/execution_spec.rb | 158 ++++++ spec/lib/msf/core/reporting/reporter_spec.rb | 113 +++++ spec/lib/msf/core/reporting/results_spec.rb | 117 +++++ .../msf/core/reporting/schema_present_spec.rb | 131 +++++ .../msf/core/reporting/test_helper_spec.rb | 88 ++++ spec/support/reporting/shared_examples.rb | 117 +++++ spec/support/reporting/test_helper.rb | 102 ++++ 50 files changed, 4686 insertions(+), 139 deletions(-) create mode 100644 lib/msf/core/reporting/backends/db_manager_backend.rb create mode 100644 lib/msf/core/reporting/backends/in_memory_backend.rb create mode 100644 lib/msf/core/reporting/connection_pool.rb create mode 100644 lib/msf/core/reporting/current_execution.rb create mode 100644 lib/msf/core/reporting/errors.rb create mode 100644 lib/msf/core/reporting/execution.rb create mode 100644 lib/msf/core/reporting/reporter.rb create mode 100644 lib/msf/core/reporting/results.rb create mode 100644 spec/lib/msf/base/simple/auxiliary_lifecycle_spec.rb create mode 100644 spec/lib/msf/base/simple/exploit_lifecycle_spec.rb create mode 100644 spec/lib/msf/base/simple/post_lifecycle_spec.rb create mode 100644 spec/lib/msf/core/auxiliary/scanner_execution_propagation_spec.rb create mode 100644 spec/lib/msf/core/exploit/remote/auto_check_phase_spec.rb create mode 100644 spec/lib/msf/core/module/fail_with_capture_spec.rb create mode 100644 spec/lib/msf/core/module/handle_exception_capture_spec.rb create mode 100644 spec/lib/msf/core/payload_generator_lifecycle_spec.rb create mode 100644 spec/lib/msf/core/payload_generator_payload_wrap_spec.rb create mode 100644 spec/lib/msf/core/reporting/backends/db_manager_backend_spec.rb create mode 100644 spec/lib/msf/core/reporting/backends/in_memory_backend_spec.rb create mode 100644 spec/lib/msf/core/reporting/connection_pool_spec.rb create mode 100644 spec/lib/msf/core/reporting/current_execution_spec.rb create mode 100644 spec/lib/msf/core/reporting/errors_spec.rb create mode 100644 spec/lib/msf/core/reporting/execution_ivar_helpers_spec.rb create mode 100644 spec/lib/msf/core/reporting/execution_phase_spec.rb create mode 100644 spec/lib/msf/core/reporting/execution_record_error_spec.rb create mode 100644 spec/lib/msf/core/reporting/execution_spec.rb create mode 100644 spec/lib/msf/core/reporting/reporter_spec.rb create mode 100644 spec/lib/msf/core/reporting/results_spec.rb create mode 100644 spec/lib/msf/core/reporting/schema_present_spec.rb create mode 100644 spec/lib/msf/core/reporting/test_helper_spec.rb create mode 100644 spec/support/reporting/shared_examples.rb create mode 100644 spec/support/reporting/test_helper.rb diff --git a/Gemfile b/Gemfile index 0a37e48d0a621..d2063db367e9e 100644 --- a/Gemfile +++ b/Gemfile @@ -3,6 +3,8 @@ source 'https://rubygems.org' # spec.add_runtime_dependency '', [] gemspec name: 'metasploit-framework' +gem 'metasploit_data_models', git: 'https://github.com/cdelafuente-r7/metasploit_data_models/', branch: 'module-execution-data-models' + # separate from test as simplecov is not run on travis-ci group :coverage do # code coverage for tests diff --git a/Gemfile.lock b/Gemfile.lock index 35074de0b00cb..f4e1bc3a1f949 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,22 @@ +GIT + remote: https://github.com/cdelafuente-r7/metasploit_data_models + revision: 7bafa640b845bfe2b3adf39351fd27986ba65385 + branch: module-execution-data-models + specs: + metasploit_data_models (6.0.19) + activerecord (>= 7.0, < 8.1) + activesupport (>= 7.0, < 8.1) + arel-helpers + bigdecimal + drb + metasploit-concern + metasploit-model (>= 5.0.4) + mutex_m + pg + railties (>= 7.0, < 8.1) + recog + webrick + GIT remote: https://github.com/rapid7/LicenseFinder revision: 2b1aefcfd6d0745d17843050c1c89f973c1fa98c @@ -370,19 +389,6 @@ GEM mutex_m railties (>= 7.0, < 8.1) metasploit-payloads (2.0.245) - metasploit_data_models (6.0.18) - activerecord (>= 7.0, < 8.1) - activesupport (>= 7.0, < 8.1) - arel-helpers - bigdecimal - drb - metasploit-concern - metasploit-model (>= 5.0.4) - mutex_m - pg - railties (>= 7.0, < 8.1) - recog - webrick metasploit_payloads-mettle (1.0.46) method_source (1.1.0) mime-types (3.7.0) @@ -715,6 +721,7 @@ DEPENDENCIES license_finder! memory_profiler metasploit-framework! + metasploit_data_models! octokit pry-byebug rake diff --git a/db/schema.rb b/db/schema.rb index 8acec12eb6b19..0fdb1b94f65fb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_04_11_000000) do +ActiveRecord::Schema[8.0].define(version: 2026_06_08_120002) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -383,6 +383,64 @@ t.index ["refname"], name: "index_module_details_on_refname" end + create_table "module_execution_errors", force: :cascade do |t| + t.bigint "module_execution_id", null: false + t.text "exception_class" + t.text "message" + t.text "backtrace" + t.text "lifecycle_phase", null: false + t.text "failure_reason" + t.timestamptz "occurred_at", null: false + t.timestamptz "created_at", null: false + t.timestamptz "updated_at", null: false + t.index ["module_execution_id", "occurred_at"], name: "idx_module_execution_errors_on_execution_and_occurred_at" + t.check_constraint "lifecycle_phase = ANY (ARRAY['setup'::text, 'check'::text, 'exploit'::text, 'cleanup'::text, 'post'::text, 'run'::text])", name: "module_execution_errors_lifecycle_phase_check" + end + + create_table "module_execution_events", force: :cascade do |t| + t.bigint "module_execution_id", null: false + t.text "name", null: false + t.jsonb "payload" + t.timestamptz "occurred_at", null: false + t.timestamptz "created_at", null: false + t.timestamptz "updated_at", null: false + t.index ["module_execution_id", "occurred_at"], name: "idx_module_execution_events_on_execution_and_occurred_at" + t.index ["name", "occurred_at"], name: "idx_module_execution_events_on_name_and_occurred_at" + end + + create_table "module_executions", force: :cascade do |t| + t.bigint "workspace_id", null: false + t.text "module_reference_name", null: false + t.text "module_type", null: false + t.text "kind", default: "run", null: false + t.jsonb "options_snapshot" + t.text "originating_ui", null: false + t.bigint "originating_user_id" + t.text "originating_token_ref" + t.bigint "parent_execution_id" + t.timestamptz "started_at", null: false + t.timestamptz "ended_at" + t.text "terminal_status" + t.text "failure_reason" + t.text "failure_message" + t.integer "single_entity_failure_count", default: 0, null: false + t.jsonb "last_single_entity_errors" + t.timestamptz "created_at", null: false + t.timestamptz "updated_at", null: false + t.index ["kind", "originating_ui"], name: "idx_module_executions_on_kind_and_originating_ui" + t.index ["module_reference_name", "started_at"], name: "idx_module_executions_on_reference_name_and_started_at", order: { started_at: :desc } + t.index ["originating_user_id"], name: "index_module_executions_on_originating_user_id" + t.index ["parent_execution_id"], name: "idx_module_executions_on_parent_execution_id_not_null", where: "(parent_execution_id IS NOT NULL)" + t.index ["workspace_id", "started_at"], name: "idx_module_executions_on_workspace_and_started_at", order: { started_at: :desc } + t.index ["workspace_id"], name: "index_module_executions_on_workspace_id" + t.check_constraint "ended_at IS NULL AND (terminal_status IS NULL OR terminal_status = 'running'::text) OR ended_at IS NOT NULL AND terminal_status IS NOT NULL AND terminal_status <> 'running'::text", name: "module_executions_terminal_status_lifecycle_check" + t.check_constraint "ended_at IS NULL OR ended_at >= started_at", name: "module_executions_ended_at_after_started_at_check" + t.check_constraint "kind = ANY (ARRAY['run'::text, 'check'::text, 'import'::text, 'direct_write'::text])", name: "module_executions_kind_check" + t.check_constraint "module_type = ANY (ARRAY['exploit'::text, 'auxiliary'::text, 'post'::text, 'payload'::text, 'encoder'::text, 'evasion'::text, 'nop'::text, 'external'::text])", name: "module_executions_module_type_check" + t.check_constraint "originating_ui = ANY (ARRAY['console'::text, 'rpc'::text, 'json_rpc'::text, 'mcp'::text, 'external'::text, 'import'::text, 'plugin'::text, 'autocheck'::text])", name: "module_executions_originating_ui_check" + t.check_constraint "terminal_status IS NULL OR (terminal_status = ANY (ARRAY['running'::text, 'success'::text, 'neutral'::text, 'expected_failure'::text, 'unhandled_exception'::text]))", name: "module_executions_terminal_status_check" + end + create_table "module_mixins", id: :serial, force: :cascade do |t| t.integer "detail_id" t.text "name" @@ -825,6 +883,11 @@ t.boolean "import_fingerprint", default: false end + add_foreign_key "module_execution_errors", "module_executions", on_delete: :cascade + add_foreign_key "module_execution_events", "module_executions", on_delete: :cascade + add_foreign_key "module_executions", "module_executions", column: "parent_execution_id" + add_foreign_key "module_executions", "users", column: "originating_user_id" + add_foreign_key "module_executions", "workspaces" add_foreign_key "service_links", "services", column: "child_id" add_foreign_key "service_links", "services", column: "parent_id" end diff --git a/lib/msf.rb b/lib/msf.rb index 2c410b40a9fd8..ab95e198bda3e 100644 --- a/lib/msf.rb +++ b/lib/msf.rb @@ -11,6 +11,7 @@ module Msf require 'msf/core/exception' # TODO: temporary require until we can split up the exceptions file and namespace properly require 'msf/core/constants' +require 'msf/core/reporting/errors' require 'msf/core/trace/certificate_trace_presenter' require 'msf_autoload' diff --git a/lib/msf/base/simple/auxiliary.rb b/lib/msf/base/simple/auxiliary.rb index 7cd7ddd7b087d..f34e093da825b 100644 --- a/lib/msf/base/simple/auxiliary.rb +++ b/lib/msf/base/simple/auxiliary.rb @@ -71,7 +71,8 @@ def self.run_simple(omod, opts = {}, job_listener: Msf::Simple::NoopJobListener. run_uuid = Rex::Text.rand_text_alphanumeric(24) job_listener.waiting run_uuid - ctx = [mod, run_uuid, job_listener] + originating_ui = opts['OriginatingUi'] || 'console' + ctx = [mod, run_uuid, job_listener, { originating_ui: originating_ui, kind: Msf::Reporting::Execution::KIND_RUN }] run_as_job = opts['RunAsJob'].nil? ? mod.passive? : opts['RunAsJob'] if run_as_job mod.job_id = mod.framework.jobs.start_bg_job( @@ -129,7 +130,8 @@ def self.check_simple(mod, opts, job_listener: Msf::Simple::NoopJobListener.inst run_uuid = Rex::Text.rand_text_alphanumeric(24) job_listener.waiting run_uuid - ctx = [mod, run_uuid, job_listener] + originating_ui = opts['OriginatingUi'] || 'console' + ctx = [mod, run_uuid, job_listener, { originating_ui: originating_ui, kind: Msf::Reporting::Execution::KIND_CHECK }] if opts['RunAsJob'] mod.job_id = mod.framework.jobs.start_bg_job( @@ -172,89 +174,156 @@ def self.job_run_proc(ctx, &block) mod = ctx[0] run_uuid = ctx[1] job_listener = ctx[2] + lifecycle_opts = ctx[3] || {} + execution_kind = lifecycle_opts[:kind] || Msf::Reporting::Execution::KIND_RUN + originating_ui = lifecycle_opts[:originating_ui] || 'console' + execution = Msf::Reporting::Execution.start!( + framework: mod.framework, + mod: mod, + originating_ui: originating_ui, + kind: execution_kind + ) + Msf::Reporting::Execution.clear_module_unhandled_exception(mod) + last_check_code = nil + result = nil + failure_exception = nil begin - begin + Msf::Reporting::CurrentExecution.with(execution) do job_listener.start run_uuid mod.check_code = nil if mod.respond_to?(:check_code=) mod.last_vuln_attempt = nil if mod.respond_to?(:last_vuln_attempt=) - mod.setup + Msf::Reporting::Execution.with_phase_setup(mod) { mod.setup } mod.framework.events.on_module_run(mod) result = block.call(mod) # Store the check result if the block returned a CheckCode mod.check_code = result if result.is_a?(Msf::Exploit::CheckCode) - job_listener.completed(run_uuid, result, mod) + last_check_code = result if result.is_a?(Msf::Exploit::CheckCode) + rescue Msf::Auxiliary::Complete => e + failure_exception = e + Msf::Reporting::Execution.with_phase_cleanup(mod) { mod.cleanup } + return + rescue Msf::Auxiliary::Failed => e + mod.error = e + failure_exception = e + mod.print_error("Auxiliary aborted due to failure: #{e.message}") + + # The caller should have already set mod.fail_reason + if mod.fail_reason == Msf::Module::Failure::None + mod.fail_reason = Msf::Module::Failure::Unknown + end + mod.fail_detail ||= e.to_s + Msf::Reporting::Execution.with_phase_cleanup(mod) { mod.cleanup } + return + rescue ::Timeout::Error => e + mod.error = e + failure_exception = e + mod.fail_reason = Msf::Module::Failure::TimeoutExpired + mod.fail_detail ||= e.to_s + mod.print_error("Auxiliary triggered a timeout exception") + Msf::Reporting::Execution.with_phase_cleanup(mod) { mod.cleanup } + return + rescue ::Interrupt => e + mod.error = e + failure_exception = e + mod.fail_reason = Msf::Module::Failure::UserInterrupt + mod.fail_detail ||= e.to_s + mod.print_error("Stopping running against current target...") + Msf::Reporting::Execution.with_phase_cleanup(mod) { mod.cleanup } + mod.print_status("Control-C again to force quit all targets.") + begin + Rex.sleep(0.5) + rescue ::Interrupt + raise $! + end + return + rescue ::Msf::OptionValidateError => e + mod.error = e + failure_exception = e + mod.fail_reason = Msf::Module::Failure::BadConfig + mod.fail_detail ||= e.to_s + ::Msf::Ui::Formatter::OptionValidateError.print_error(mod, e) rescue ::Exception => e - job_listener.failed(run_uuid, e, mod) - raise - end - rescue Msf::Auxiliary::Complete - mod.cleanup - return - rescue Msf::Auxiliary::Failed => e - mod.error = e - mod.print_error("Auxiliary aborted due to failure: #{e.message}") - - # The caller should have already set mod.fail_reason - if mod.fail_reason == Msf::Module::Failure::None + mod.error = e + failure_exception = e mod.fail_reason = Msf::Module::Failure::Unknown - end - mod.fail_detail ||= e.to_s - - mod.cleanup - return - rescue ::Timeout::Error => e - mod.error = e - mod.fail_reason = Msf::Module::Failure::TimeoutExpired - mod.fail_detail ||= e.to_s - mod.print_error("Auxiliary triggered a timeout exception") - mod.cleanup - return - rescue ::Interrupt => e - mod.error = e - mod.fail_reason = Msf::Module::Failure::UserInterrupt - mod.fail_detail ||= e.to_s - mod.print_error("Stopping running against current target...") - mod.cleanup - mod.print_status("Control-C again to force quit all targets.") - begin - Rex.sleep(0.5) - rescue ::Interrupt - raise $! - end - return - rescue ::Msf::OptionValidateError => e - mod.error = e - mod.fail_reason = Msf::Module::Failure::BadConfig - mod.fail_detail ||= e.to_s - ::Msf::Ui::Formatter::OptionValidateError.print_error(mod, e) - rescue ::Exception => e - mod.error = e - mod.fail_reason = Msf::Module::Failure::Unknown - mod.fail_detail ||= e.to_s - mod.print_error("Auxiliary failed: #{e.class} #{e}") - if(e.class.to_s != 'Msf::OptionValidateError') - mod.print_error("Call stack:") - e.backtrace.each do |line| - break if line =~ /lib.msf.base.simple.auxiliary.rb/ - mod.print_error(" #{line}") + mod.fail_detail ||= e.to_s + Msf::Reporting::Execution.mark_module_unhandled_exception(mod) + mod.print_error("Auxiliary failed: #{e.class} #{e}") + if(e.class.to_s != 'Msf::OptionValidateError') + mod.print_error("Call stack:") + e.backtrace.each do |line| + break if line =~ /lib.msf.base.simple.auxiliary.rb/ + mod.print_error(" #{line}") + end end + + elog('Auxiliary failed', error: e) + Msf::Reporting::Execution.capture_exception!(mod, e, failure_reason: mod.fail_reason) + Msf::Reporting::Execution.with_phase_cleanup(mod) { mod.cleanup } + end + ensure + # Notify the job listener exactly once. +failure_exception+ is + # populated by every rescue branch (including +Msf::Auxiliary::Complete+, + # which the listener historically receives via +failed+); a +nil+ + # value indicates the success path completed without raising. + if failure_exception + job_listener.failed(run_uuid, failure_exception, mod) + else + job_listener.completed(run_uuid, result, mod) end - elog('Auxiliary failed', error: e) - mod.cleanup + # Register an attempt in the database (an `Mdm::ExploitAttempt` (and + # possibly an `Mdm::VulnAttempt`). + # + # Since auxiliary modules don't report clearly when it is a success or a + # failure, we are calling #report_failure keeping the `mod.fail_reason` + # value unchanged. This value is set to `Msf::Module::Failure::None` when + # no error was reported. It should be set to another + # `Msf::Module::Failure::*` value otherwise. + mod.report_failure + if execution + terminal_status, failure_reason, failure_message = + derive_terminal_status(mod, execution_kind, last_check_code) + Msf::Reporting::Execution.finalize!( + execution, + terminal_status: terminal_status, + failure_reason: failure_reason, + failure_message: failure_message + ) + end end return result - ensure - # Register an attempt in the database (an `Mdm::ExploitAttempt` (and - # possibly an `Mdm::VulnAttempt`). - # - # Since auxiliary modules don't report clearly when it is a success or a - # failure, we are calling #report_failure keeping the `mod.fail_reason` - # value unchanged. This value is set to `Msf::Module::Failure::None` when - # no error was reported. It should be set to another - # `Msf::Module::Failure::*` value otherwise. - mod.report_failure + end + + # Translate the auxiliary module's post-run state into a terminal + # status string. + # + # @api private + def self.derive_terminal_status(mod, execution_kind, last_check_code) + unhandled = Msf::Reporting::Execution.module_unhandled_exception?(mod) + + if execution_kind == Msf::Reporting::Execution::KIND_CHECK + if unhandled + return [Msf::Reporting::Execution::TERMINAL_UNHANDLED_EXCEPTION, nil, mod.error&.message] + end + + return [Msf::Reporting::Execution.terminal_status_for_check_code(last_check_code), nil, nil] + end + + if unhandled + reason = mod.fail_reason if mod.respond_to?(:fail_reason) + reason = nil if reason == Msf::Module::Failure::None + return [Msf::Reporting::Execution::TERMINAL_UNHANDLED_EXCEPTION, reason, mod.error&.message] + end + + reason = mod.fail_reason if mod.respond_to?(:fail_reason) + if reason && reason != Msf::Module::Failure::None + detail = mod.respond_to?(:fail_detail) ? mod.fail_detail : nil + [Msf::Reporting::Execution::TERMINAL_EXPECTED_FAILURE, reason, detail] + else + [Msf::Reporting::Execution::TERMINAL_SUCCESS, nil, nil] + end end # diff --git a/lib/msf/base/simple/evasion.rb b/lib/msf/base/simple/evasion.rb index 9bbd3fc74f29f..c88330abfbc9b 100644 --- a/lib/msf/base/simple/evasion.rb +++ b/lib/msf/base/simple/evasion.rb @@ -9,6 +9,7 @@ module Evasion def self.run_simple(oevasion, opts, &block) evasion = oevasion.replicant + execution = nil # Trap and print errors here (makes them UI-independent) begin # Clone the module to prevent changes to the original instance @@ -80,8 +81,29 @@ def self.run_simple(oevasion, opts, &block) driver.use_job = true end + execution = Msf::Reporting::Execution.start!( + framework: evasion.framework, + mod: evasion, + originating_ui: opts['OriginatingUi'] || 'console', + kind: Msf::Reporting::Execution::KIND_RUN + ) + # Let's rock this party - driver.run + Msf::Reporting::CurrentExecution.with(execution) do + driver.run + rescue ::Interrupt + evasion.error = $! + raise $! + rescue ::Msf::OptionValidateError => e + evasion.error = e + ::Msf::Ui::Formatter::OptionValidateError.print_error(evasion, e) + rescue ::Exception => e + evasion.error = e + Msf::Reporting::Execution.mark_module_unhandled_exception(evasion) + evasion.print_error("evasion failed: #{e}") + elog("Evasion failed (#{evasion.refname})", error: e) + Msf::Reporting::Execution.capture_exception!(evasion, e) + end # Save the job identifier this evasion is running as evasion.job_id = driver.job_id @@ -96,8 +118,30 @@ def self.run_simple(oevasion, opts, &block) ::Msf::Ui::Formatter::OptionValidateError.print_error(evasion, e) rescue ::Exception => e evasion.error = e + Msf::Reporting::Execution.mark_module_unhandled_exception(evasion) evasion.print_error("evasion failed: #{e}") elog("Evasion failed (#{evasion.refname})", error: e) + ensure + if execution + terminal_status, failure_reason, failure_message = + if Msf::Reporting::Execution.module_unhandled_exception?(evasion) + reason = evasion.respond_to?(:fail_reason) ? evasion.fail_reason : nil + reason = nil if reason == Msf::Module::Failure::None + [Msf::Reporting::Execution::TERMINAL_UNHANDLED_EXCEPTION, reason, evasion.error&.message] + elsif evasion.error + reason = evasion.respond_to?(:fail_reason) ? evasion.fail_reason : nil + reason = nil if reason == Msf::Module::Failure::None + [Msf::Reporting::Execution::TERMINAL_EXPECTED_FAILURE, reason, evasion.error.message] + else + [Msf::Reporting::Execution::TERMINAL_SUCCESS, nil, nil] + end + Msf::Reporting::Execution.finalize!( + execution, + terminal_status: terminal_status, + failure_reason: failure_reason, + failure_message: failure_message + ) + end end nil diff --git a/lib/msf/base/simple/exploit.rb b/lib/msf/base/simple/exploit.rb index 479239a20a510..7ece1b259cf6a 100644 --- a/lib/msf/base/simple/exploit.rb +++ b/lib/msf/base/simple/exploit.rb @@ -56,6 +56,7 @@ module Exploit # def self.exploit_simple(oexploit, opts, &block) exploit = oexploit.replicant + execution = nil # Trap and print errors here (makes them UI-independent) begin # Clone the module to prevent changes to the original instance @@ -141,8 +142,29 @@ def self.exploit_simple(oexploit, opts, &block) driver.use_job = true end + execution = Msf::Reporting::Execution.start!( + framework: exploit.framework, + mod: exploit, + originating_ui: opts['OriginatingUi'] || 'console', + kind: Msf::Reporting::Execution::KIND_RUN + ) + # Let's rock this party - driver.run + Msf::Reporting::CurrentExecution.with(execution) do + driver.run + rescue ::Interrupt, ::Msf::OptionValidateError + # Defer handling to the outer rescue chain. Re-raising while + # CurrentExecution is still bound is a no-op for these classes + # (they intentionally are not recorded as Mdm::ModuleExecutionError). + raise + rescue ::Exception => e + # capture_exception! requires +CurrentExecution.current+ to be + # bound; do it here, then defer printing/logging/state mutation + # to the outer +rescue ::Exception+ branch which would otherwise + # duplicate this code. + Msf::Reporting::Execution.capture_exception!(exploit, e) + raise + end # Save the job identifier this exploit is running as exploit.job_id = driver.job_id @@ -158,14 +180,63 @@ def self.exploit_simple(oexploit, opts, &block) return false rescue ::Exception => e exploit.error = e + Msf::Reporting::Execution.mark_module_unhandled_exception(exploit) exploit.print_error("Exploit failed: #{e}") elog("Exploit failed (#{exploit.refname})", error: e) + ensure + finalize_exploit_execution(execution, exploit) if execution end return driver.session if driver nil end + # Translate the exploit's post-run state into a terminal status and + # call +Msf::Reporting::Execution.finalize!+. Handler-style (passive) + # exploits intentionally stay in +running+ state because they remain + # alive across multiple session events for the handler's entire + # lifetime, with no per-job hook to finalize them. + # + # The +unhandled_exception+ status is signalled by the IVAR set via + # {Msf::Reporting::Execution.mark_module_unhandled_exception}, which + # +exploit_simple+'s outer +rescue ::Exception+ sets for exceptions + # that bubbled out and +Msf::Exploit#handle_exception+ sets for + # exceptions that +Msf::ExploitDriver#job_run_proc+ caught and + # swallowed. + # + # @api private + def self.finalize_exploit_execution(execution, exploit) + return if exploit.respond_to?(:passive?) && exploit.passive? + + if Msf::Reporting::Execution.module_unhandled_exception?(exploit) + reason = exploit.respond_to?(:fail_reason) ? exploit.fail_reason : nil + reason = nil if reason == Msf::Module::Failure::None + Msf::Reporting::Execution.finalize!( + execution, + terminal_status: Msf::Reporting::Execution::TERMINAL_UNHANDLED_EXCEPTION, + failure_reason: reason, + failure_message: exploit.error&.message + ) + return + end + + reason = exploit.respond_to?(:fail_reason) ? exploit.fail_reason : nil + if reason && reason != Msf::Module::Failure::None + detail = exploit.respond_to?(:fail_detail) ? exploit.fail_detail : nil + Msf::Reporting::Execution.finalize!( + execution, + terminal_status: Msf::Reporting::Execution::TERMINAL_EXPECTED_FAILURE, + failure_reason: reason, + failure_message: detail + ) + else + Msf::Reporting::Execution.finalize!( + execution, + terminal_status: Msf::Reporting::Execution::TERMINAL_SUCCESS + ) + end + end + # # Calls the class method. # @@ -205,7 +276,7 @@ def self.check_simple(mod, opts, job_listener: Msf::Simple::NoopJobListener.inst run_uuid = Rex::Text.rand_text_alphanumeric(24) job_listener.waiting run_uuid - ctx = [mod, run_uuid, job_listener] + ctx = [mod, run_uuid, job_listener, { originating_ui: opts['OriginatingUi'] || 'console' }] if opts['RunAsJob'] mod.job_id = mod.framework.jobs.start_bg_job( @@ -233,16 +304,57 @@ def self.job_check_proc(ctx) mod = ctx[0] run_uuid = ctx[1] job_listener = ctx[2] + lifecycle_opts = ctx[3] || {} + execution = Msf::Reporting::Execution.start!( + framework: mod.framework, + mod: mod, + originating_ui: lifecycle_opts[:originating_ui] || 'console', + kind: Msf::Reporting::Execution::KIND_CHECK + ) + result = nil + failed_exception = nil + Msf::Reporting::Execution.clear_module_unhandled_exception(mod) begin - job_listener.start run_uuid - mod.setup - result = mod.check - job_listener.completed(run_uuid, result, mod) - rescue => e - job_listener.failed(run_uuid, e, mod) - mod.handle_exception e + Msf::Reporting::CurrentExecution.with(execution) do + job_listener.start run_uuid + Msf::Reporting::Execution.with_phase_setup(mod) { mod.setup } + result = mod.check + job_listener.completed(run_uuid, result, mod) + rescue ::Msf::Exploit::Failed => e + # Heads-up: check methods are contracted to return a CheckCode + # and never raise. Defensive path for modules that call + # fail_with from check anyway -- record as expected_failure, + # not unhandled_exception. + failed_exception = e + job_listener.failed(run_uuid, e, mod) + mod.handle_exception e + rescue => e + Msf::Reporting::Execution.mark_module_unhandled_exception(mod) + job_listener.failed(run_uuid, e, mod) + mod.handle_exception e + ensure + Msf::Reporting::Execution.with_phase_cleanup(mod) { mod.cleanup } + end ensure - mod.cleanup + if execution + terminal_status, failure_reason, failure_message = + if Msf::Reporting::Execution.module_unhandled_exception?(mod) + [Msf::Reporting::Execution::TERMINAL_UNHANDLED_EXCEPTION, nil, mod.error&.message] + elsif failed_exception + reason = mod.respond_to?(:fail_reason) ? mod.fail_reason : nil + reason = nil if reason == Msf::Module::Failure::None + detail = mod.respond_to?(:fail_detail) ? mod.fail_detail : nil + [Msf::Reporting::Execution::TERMINAL_EXPECTED_FAILURE, reason, detail || failed_exception.message] + else + [Msf::Reporting::Execution.terminal_status_for_check_code(result), nil, nil] + end + Msf::Reporting::Execution.finalize!( + execution, + terminal_status: terminal_status, + failure_reason: failure_reason, + failure_message: failure_message + ) + end end return result diff --git a/lib/msf/base/simple/post.rb b/lib/msf/base/simple/post.rb index 6e329c79aca44..cded9931fa16c 100644 --- a/lib/msf/base/simple/post.rb +++ b/lib/msf/base/simple/post.rb @@ -38,7 +38,6 @@ module Post # job. # def self.run_simple(omod, opts = {}, &block) - # Clone the module to prevent changes to the original instance mod = omod.replicant Msf::Simple::Framework.simplify_module(mod) @@ -68,7 +67,7 @@ def self.run_simple(omod, opts = {}, &block) # Disable this until we can test background stuff a little better # if(mod.passive? or opts['RunAsJob']) - ctx = [ mod.replicant ] + ctx = [ mod.replicant, { originating_ui: opts['OriginatingUi'] || 'console' } ] mod.job_id = mod.framework.jobs.start_bg_job( "Post: #{mod.refname}", ctx, @@ -78,7 +77,7 @@ def self.run_simple(omod, opts = {}, &block) # Propagate this back to the caller for console mgmt omod.job_id = mod.job_id else - ctx = [ mod ] + ctx = [ mod, { originating_ui: opts['OriginatingUi'] || 'console' } ] self.job_run_proc(ctx) self.job_cleanup_proc(ctx) end @@ -100,8 +99,16 @@ def run_simple(opts = {}, &block) # def self.job_run_proc(ctx) mod = ctx[0] - begin - mod.setup + lifecycle_opts = ctx[1] || {} + execution = Msf::Reporting::Execution.start!( + framework: mod.framework, + mod: mod, + originating_ui: lifecycle_opts[:originating_ui] || 'console', + kind: Msf::Reporting::Execution::KIND_RUN + ) + Msf::Reporting::Execution.clear_module_unhandled_exception(mod) + Msf::Reporting::CurrentExecution.with(execution) do + Msf::Reporting::Execution.with_phase_setup(mod) { mod.setup } mod.framework.events.on_module_run(mod) # Grab the session object since we need to fire an event for not # only the normal module_run event that all module types have to @@ -112,32 +119,33 @@ def self.job_run_proc(ctx) mod.run else mod.print_error("Session not found") - mod.cleanup + Msf::Reporting::Execution.with_phase_cleanup(mod) { mod.cleanup } return end rescue Msf::Post::Complete - mod.cleanup + Msf::Reporting::Execution.with_phase_cleanup(mod) { mod.cleanup } return rescue Msf::Post::Failed => e mod.error = e mod.print_error("Post aborted due to failure: #{e.message}") - mod.cleanup + Msf::Reporting::Execution.with_phase_cleanup(mod) { mod.cleanup } return rescue ::Timeout::Error => e mod.error = e mod.print_error("Post triggered a timeout exception") - mod.cleanup + Msf::Reporting::Execution.with_phase_cleanup(mod) { mod.cleanup } return rescue ::Interrupt => e mod.error = e mod.print_error("Post interrupted by the console user") - mod.cleanup + Msf::Reporting::Execution.with_phase_cleanup(mod) { mod.cleanup } return rescue ::Msf::OptionValidateError => e mod.error = e ::Msf::Ui::Formatter::OptionValidateError.print_error(mod, e) rescue ::Exception => e mod.error = e + Msf::Reporting::Execution.mark_module_unhandled_exception(mod) mod.print_error("Post failed: #{e.class} #{e}") if(e.class.to_s != 'Msf::OptionValidateError') mod.print_error("Call stack:") @@ -148,10 +156,32 @@ def self.job_run_proc(ctx) end elog('Post failed', error: e) - mod.cleanup + Msf::Reporting::Execution.capture_exception!(mod, e) + Msf::Reporting::Execution.with_phase_cleanup(mod) { mod.cleanup } return end + ensure + if execution + terminal_status, failure_reason, failure_message = + if Msf::Reporting::Execution.module_unhandled_exception?(mod) + reason = mod.respond_to?(:fail_reason) ? mod.fail_reason : nil + reason = nil if reason == Msf::Module::Failure::None + [Msf::Reporting::Execution::TERMINAL_UNHANDLED_EXCEPTION, reason, mod.error&.message] + elsif mod.error + reason = mod.respond_to?(:fail_reason) ? mod.fail_reason : nil + reason = nil if reason == Msf::Module::Failure::None + [Msf::Reporting::Execution::TERMINAL_EXPECTED_FAILURE, reason, mod.error.message] + else + [Msf::Reporting::Execution::TERMINAL_SUCCESS, nil, nil] + end + Msf::Reporting::Execution.finalize!( + execution, + terminal_status: terminal_status, + failure_reason: failure_reason, + failure_message: failure_message + ) + end end # diff --git a/lib/msf/core/auxiliary.rb b/lib/msf/core/auxiliary.rb index 6cebbf7b52363..a6505939dd87e 100644 --- a/lib/msf/core/auxiliary.rb +++ b/lib/msf/core/auxiliary.rb @@ -168,7 +168,10 @@ def fail_with(reason, msg = nil) end self.fail_detail = msg - raise Msf::Auxiliary::Failed, "#{reason.to_s}: #{(msg || "No failure message given")}" + Msf::Reporting::Execution.record_failure!(self, failure_reason: fail_reason, message: msg) + exception = Msf::Auxiliary::Failed.new("#{reason}: #{msg || 'No failure message given'}") + Msf::Reporting::Execution.mark_exception_recorded(exception) + raise exception end # diff --git a/lib/msf/core/auxiliary/scanner.rb b/lib/msf/core/auxiliary/scanner.rb index 721b0e113a551..e720cb29d8f72 100644 --- a/lib/msf/core/auxiliary/scanner.rb +++ b/lib/msf/core/auxiliary/scanner.rb @@ -101,6 +101,8 @@ def run end end + parent_execution = Msf::Reporting::CurrentExecution.current + begin if (self.respond_to?('run_host')) @@ -126,7 +128,7 @@ def run nmod = self.replicant nmod.datastore = thr_datastore - begin + Msf::Reporting::CurrentExecution.with(parent_execution) do res << { targ => nmod.run_host(targ) } rescue ::Rex::BindFailed if datastore['CHOST'] @@ -138,10 +140,11 @@ def run rescue ::Interrupt,::NoMethodError, ::RuntimeError, ::ArgumentError, ::NameError raise $! rescue ::Exception => e + Msf::Reporting::Execution.capture_exception!(nmod, e) print_status("Error: #{targ}: #{e.class} #{e.message}") elog("Error running against host #{targ}", error: e) ensure - nmod.cleanup + Msf::Reporting::Execution.with_phase_cleanup(nmod) { nmod.cleanup } end end end @@ -215,7 +218,7 @@ def run thread = framework.threads.spawn("ScannerBatch(#{self.refname})", false, batch) do |bat| nmod = self.replicant mybatch = bat.dup - begin + Msf::Reporting::CurrentExecution.with(parent_execution) do nmod.run_batch(mybatch) rescue ::Rex::BindFailed if datastore['CHOST'] @@ -227,9 +230,10 @@ def run rescue ::Interrupt,::NoMethodError, ::RuntimeError, ::ArgumentError, ::NameError raise $! rescue ::Exception => e + Msf::Reporting::Execution.capture_exception!(nmod, e) print_status("Error: #{mybatch[0]}-#{mybatch[-1]}: #{e}") ensure - nmod.cleanup + Msf::Reporting::Execution.with_phase_cleanup(nmod) { nmod.cleanup } end end thread[:batch_size] = batch.length @@ -354,13 +358,11 @@ def add_delay_jitter(_delay, _jitter) end def fail_with(reason, msg = nil, abort: false) - if abort - # raising Failed will case the run to be aborted - raise Msf::Auxiliary::Failed, "#{reason.to_s}: #{msg}" - else - # raising AttemptFailed will cause the run_host / run_batch to be aborted - raise Msf::Auxiliary::Scanner::AttemptFailed, "#{reason.to_s}: #{msg}" - end + Msf::Reporting::Execution.record_failure!(self, failure_reason: reason, message: msg) + exception_class = abort ? Msf::Auxiliary::Failed : Msf::Auxiliary::Scanner::AttemptFailed + exception = exception_class.new("#{reason}: #{msg}") + Msf::Reporting::Execution.mark_exception_recorded(exception) + raise exception end end diff --git a/lib/msf/core/evasion.rb b/lib/msf/core/evasion.rb index 944dee352b5e3..9a4a3e7d5a962 100644 --- a/lib/msf/core/evasion.rb +++ b/lib/msf/core/evasion.rb @@ -151,7 +151,10 @@ def cleanup end def fail_with(reason, msg=nil) - raise Msf::Evasion::Failed, "#{reason}: #{msg}" + Msf::Reporting::Execution.record_failure!(self, failure_reason: reason, message: msg) + exception = Msf::Evasion::Failed.new("#{reason}: #{msg}") + Msf::Reporting::Execution.mark_exception_recorded(exception) + raise exception end def evasion_commands diff --git a/lib/msf/core/exploit.rb b/lib/msf/core/exploit.rb index d21b2c78ac2cf..698cc10cb2613 100644 --- a/lib/msf/core/exploit.rb +++ b/lib/msf/core/exploit.rb @@ -1356,7 +1356,10 @@ def fail_with(reason,msg=nil) end self.fail_detail = msg - raise Msf::Exploit::Failed, (msg || "No failure message given") + Msf::Reporting::Execution.record_failure!(self, failure_reason: fail_reason, message: msg) + exception = Msf::Exploit::Failed.new(msg || 'No failure message given') + Msf::Reporting::Execution.mark_exception_recorded(exception) + raise exception end def setup_fail_detail_from_exception e @@ -1451,6 +1454,19 @@ def handle_exception e # Record the error to various places self.framework.events.on_module_error(self, msg) + # Persist this exception against the current Mdm::ModuleExecution + # row. Skipped on Msf::Exploit::Failed because fail_with already + # recorded it. Anything else is, from the framework's POV, an + # unhandled exception that the driver swallowed via this method; + # mark the module so the wrapping +finalize_exploit_execution+ + # records the parent execution as +unhandled_exception+ instead of + # misclassifying it as +expected_failure+ when handle_exception + # falls back to fail_reason: Unknown. + unless e.is_a?(Msf::Exploit::Failed) + Msf::Reporting::Execution.capture_exception!(self, e, failure_reason: fail_reason) + Msf::Reporting::Execution.mark_module_unhandled_exception(self) + end + # Report the failure (and attempt) in the database self.report_failure diff --git a/lib/msf/core/exploit/remote/auto_check.rb b/lib/msf/core/exploit/remote/auto_check.rb index bc71d16df97f9..db5be8abea732 100644 --- a/lib/msf/core/exploit/remote/auto_check.rb +++ b/lib/msf/core/exploit/remote/auto_check.rb @@ -40,7 +40,7 @@ def with_prepended_auto_check warning_msg = 'ForceExploit is enabled, proceeding with exploitation.' error_msg = '"set ForceExploit true" to override check result.' - check_code = check + check_code = run_with_check_execution { check } case check_code when Exploit::CheckCode::Vulnerable, Exploit::CheckCode::Appears @@ -89,5 +89,65 @@ def with_prepended_auto_check fail_with(failure_type, "#{check_code.message} #{error_msg}") end + # Wrap an embedded +check+ call in a child +Mdm::ModuleExecution+ + # row with +kind: 'check'+ so any failure raised from inside +check+ + # (including +fail_with+-driven failures) is attributed to the + # +check+ phase via {Msf::Reporting::Execution.phase_for}'s + # +kind == 'check'+ branch, even though the surrounding exploit + # execution is +kind: 'run'+. + def run_with_check_execution + parent_execution = Msf::Reporting::CurrentExecution.current + check_execution = Msf::Reporting::Execution.start!( + framework: framework, + mod: self, + originating_ui: 'autocheck', + parent_execution_id: parent_execution&.id, + kind: Msf::Reporting::Execution::KIND_CHECK + ) + + check_code = nil + captured_exception = nil + failed_exception = nil + Msf::Reporting::Execution.clear_module_unhandled_exception(self) + begin + Msf::Reporting::CurrentExecution.with(check_execution) do + check_code = yield + rescue ::Msf::Exploit::Failed => e + # Heads-up: check methods are contracted to return a CheckCode + # and never raise. Defensive path for modules that call + # fail_with from check anyway -- record as expected_failure, + # not unhandled_exception. + failed_exception = e + raise + rescue ::Exception => e # rubocop:disable Lint/RescueException -- record before exiting CurrentExecution.with so phase resolves to 'check' + captured_exception = e + Msf::Reporting::Execution.mark_module_unhandled_exception(self) + Msf::Reporting::Execution.capture_exception!(self, e) + raise + end + ensure + if check_execution + terminal_status, failure_reason, failure_message = + if Msf::Reporting::Execution.module_unhandled_exception?(self) + [Msf::Reporting::Execution::TERMINAL_UNHANDLED_EXCEPTION, nil, captured_exception&.message] + elsif failed_exception + reason = fail_reason + reason = nil if reason == Msf::Module::Failure::None + [Msf::Reporting::Execution::TERMINAL_EXPECTED_FAILURE, reason, fail_detail || failed_exception.message] + else + [Msf::Reporting::Execution.terminal_status_for_check_code(check_code), nil, nil] + end + Msf::Reporting::Execution.finalize!( + check_execution, + terminal_status: terminal_status, + failure_reason: failure_reason, + failure_message: failure_message + ) + end + end + + check_code + end + end end diff --git a/lib/msf/core/exploit_driver.rb b/lib/msf/core/exploit_driver.rb index f0e51b8bb5d24..bcb97748dba05 100644 --- a/lib/msf/core/exploit_driver.rb +++ b/lib/msf/core/exploit_driver.rb @@ -216,7 +216,7 @@ def job_run_proc(ctx) delay = nil if exploit.passive? # Set the exploit up the bomb - exploit.setup + Msf::Reporting::Execution.with_phase_setup(exploit) { exploit.setup } exploit.framework.events.on_module_run(exploit) @@ -274,7 +274,9 @@ def job_cleanup_proc(ctx) exploit.framework.events.on_module_complete(exploit) # Allow the exploit to cleanup after itself, that messy bugger. - semaphore.synchronize { exploit.cleanup } + semaphore.synchronize do + Msf::Reporting::Execution.with_phase_cleanup(exploit) { exploit.cleanup } + end end end diff --git a/lib/msf/core/module.rb b/lib/msf/core/module.rb index 3eb8b3d4115b8..c8bbe0878225d 100644 --- a/lib/msf/core/module.rb +++ b/lib/msf/core/module.rb @@ -318,7 +318,10 @@ def debugging? # fail_with('No Access', 'Unable to login') # def fail_with(reason, msg=nil) - raise RuntimeError, "#{reason.to_s}: #{msg}" + Msf::Reporting::Execution.record_failure!(self, failure_reason: reason, message: msg) + exception = RuntimeError.new("#{reason}: #{msg}") + Msf::Reporting::Execution.mark_exception_recorded(exception) + raise exception end diff --git a/lib/msf/core/payload_generator.rb b/lib/msf/core/payload_generator.rb index 8dbea099da680..5e022f2955ef5 100644 --- a/lib/msf/core/payload_generator.rb +++ b/lib/msf/core/payload_generator.rb @@ -468,13 +468,15 @@ def generate_raw_payload raise IncompatibleArch, "The selected arch is incompatible with the payload" end - payload_module.generate_simple( - 'Format' => 'raw', - 'Options' => datastore, - 'Encoder' => nil, - 'MaxSize' => @space, - 'DisableNops' => true - ) + wrap_with_execution_lifecycle(payload_module) do + payload_module.generate_simple( + 'Format' => 'raw', + 'Options' => datastore, + 'Encoder' => nil, + 'MaxSize' => @space, + 'DisableNops' => true + ) + end end end @@ -545,7 +547,9 @@ def prepend_nops(shellcode) framework.nops.each_module_ranked('Arch' => [arch]) do |name, mod| nop = framework.nops.create(name) - raw = nop.generate_sled(nops, {'BadChars' => badchars, 'SaveRegisters' => [ 'esp', 'ebp', 'esi', 'edi' ] }) + raw = wrap_with_execution_lifecycle(nop) do + nop.generate_sled(nops, {'BadChars' => badchars, 'SaveRegisters' => [ 'esp', 'ebp', 'esi', 'edi' ] }) + end if raw cli_print "Successfully added NOP sled of size #{raw.length} from #{name}" return raw + shellcode @@ -561,18 +565,54 @@ def prepend_nops(shellcode) # @return [String] The encoded shellcode # @raise [Msf::EncoderSpaceViolation] If the Encoder makes the shellcode larger than the supplied space limit def run_encoder(encoder_module, shellcode) - iterations.times do |x| - shellcode = encoder_module.encode(shellcode.dup, badchars, nil, platform_list) - cli_print "#{encoder_module.refname} succeeded with size #{shellcode.length} (iteration=#{x})" - if shellcode.length > encoder_space - raise EncoderSpaceViolation, "encoder has made a buffer that is too big" + wrap_with_execution_lifecycle(encoder_module) do + iterations.times do |x| + shellcode = encoder_module.encode(shellcode.dup, badchars, nil, platform_list) + cli_print "#{encoder_module.refname} succeeded with size #{shellcode.length} (iteration=#{x})" + if shellcode.length > encoder_space + raise EncoderSpaceViolation, "encoder has made a buffer that is too big" + end end + shellcode end - shellcode end private + # Bracket subordinate encoder/NOP work in a `Mdm::ModuleExecution` + # row when invoked standalone (e.g. msfvenom). When called from + # inside another module's execution (e.g. an exploit crafting its + # payload), the existing execution is reused so the framework does + # not double-count subordinate component runs. + # + # @api private + def wrap_with_execution_lifecycle(mod, &block) + return block.call if Msf::Reporting::CurrentExecution.current + + execution = Msf::Reporting::Execution.start!( + framework: framework, + mod: mod, + originating_ui: 'console', + kind: Msf::Reporting::Execution::KIND_RUN + ) + unhandled_exception = false + begin + Msf::Reporting::CurrentExecution.with(execution, &block) + rescue StandardError + unhandled_exception = true + raise + ensure + if execution + terminal_status = if unhandled_exception + Msf::Reporting::Execution::TERMINAL_UNHANDLED_EXCEPTION + else + Msf::Reporting::Execution::TERMINAL_SUCCESS + end + Msf::Reporting::Execution.finalize!(execution, terminal_status: terminal_status) + end + end + end + # This method prints output to the console if running in CLI mode # @param [String] message The message to print to the console. def cli_print(message= '') diff --git a/lib/msf/core/post.rb b/lib/msf/core/post.rb index 0bc801ea8edcb..0495f45fc75e0 100644 --- a/lib/msf/core/post.rb +++ b/lib/msf/core/post.rb @@ -69,7 +69,10 @@ def session_db_id # Override Msf::Module#fail_with for Msf::Simple::Post::job_run_proc def fail_with(reason, msg = nil) - raise Msf::Post::Failed, "#{reason.to_s}: #{msg}" + Msf::Reporting::Execution.record_failure!(self, failure_reason: reason, message: msg) + exception = Msf::Post::Failed.new("#{reason}: #{msg}") + Msf::Reporting::Execution.mark_exception_recorded(exception) + raise exception end end diff --git a/lib/msf/core/reporting/backends/db_manager_backend.rb b/lib/msf/core/reporting/backends/db_manager_backend.rb new file mode 100644 index 0000000000000..ffaec7b6a1d1e --- /dev/null +++ b/lib/msf/core/reporting/backends/db_manager_backend.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Msf + module Reporting + module Backends + # Reporting backend backed by the local +Msf::DBManager+ (the default + # driver used by msfconsole when a PostgreSQL connection is + # configured). + # + # Only the single-entity methods that have been migrated to this + # backend are implemented as delegating shims; calling any other + # reporting method falls through to the +Msf::Reporting::Reporter+ + # stub surface, which returns +Skipped(reason: :not_implemented)+. + class DbManagerBackend + # @return [Msf::Framework] + attr_reader :framework + + # @param framework [Msf::Framework] host framework whose + # +#db+ accessor returns an active +Msf::DBManager+. + def initialize(framework:) + @framework = framework + end + + # Persist or find-or-create a host row. + # + # Accepts the same opts-hash shape +Msf::DBManager#report_host+ + # accepts, and translates the contract's +address:+ kwarg into + # the +:host+ key before delegating. + # + # @param address [String, ::Mdm::Host] required. + # @param kwargs additional opts forwarded verbatim to + # +Msf::DBManager#report_host+ (e.g. +:workspace+, +:os_name+, + # +:mac+). + # @return [Msf::Reporting::Results::Persisted, + # Msf::Reporting::Results::Skipped, + # Msf::Reporting::Results::Failed] + def report_host(address:, **kwargs) + return Results::Skipped.new(entity_type: :host, reason: :db_inactive) unless db_active? + + ConnectionPool.with_connection do + begin + row = @framework.db.report_host(kwargs.merge(host: address)) + rescue StandardError => e + return Results::Failed.new(entity_type: :host, error: wrap_error(e)) + end + + if row.nil? + Results::Skipped.new(entity_type: :host, reason: :db_inactive) + else + Results::Persisted.new(entity_type: :host, row_id: row.id, row: row, touched: false) + end + end + end + + private + + def db_active? + @framework && + @framework.respond_to?(:db) && + @framework.db && + @framework.db.respond_to?(:active) && + @framework.db.active + end + + def wrap_error(exception) + return exception if exception.is_a?(BackendError) + + BackendError.new(exception.message) + end + end + end + end +end diff --git a/lib/msf/core/reporting/backends/in_memory_backend.rb b/lib/msf/core/reporting/backends/in_memory_backend.rb new file mode 100644 index 0000000000000..ebb58ed8980b2 --- /dev/null +++ b/lib/msf/core/reporting/backends/in_memory_backend.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +module Msf + module Reporting + module Backends + # Test-only reporting backend. + # + # Records every reporting call in declaration order and returns + # typed +Msf::Reporting::Results::*+ values so module-author specs + # can assert on what would have been written, without booting + # PostgreSQL or loading the +metasploit_data_models+ schema. + class InMemoryBackend + # @return [Array] ordered list of recorded calls. Each + # entry has shape: + # +{ entity_type:, method:, kwargs: }+. The +kwargs+ hash is + # frozen. + attr_reader :calls + + # Map of public method name → entity_type symbol attributed to + # the recorded call. Mirrors the table in + # contracts/reporter-api.md. + ENTITY_TYPE_BY_METHOD = { + report_host: :host, + report_service: :service, + report_vuln: :vuln, + report_vuln_attempt: :vuln_attempt, + report_exploit_attempt: :exploit_attempt, + report_note: :note, + report_loot: :loot, + report_client: :client, + report_web_site: :web_site, + report_web_page: :web_page, + report_web_form: :web_form, + report_web_vuln: :web_vuln, + report_session_event: :session_event, + create_credential: :credential, + create_credential_login: :credential_login, + invalidate_login: :login + }.freeze + + def initialize + @calls = [] + @next_row_id = 0 + @db_inactive = false + @failures = Hash.new { |h, k| h[k] = [] } + end + + # Toggle the simulated DB-inactive state. Subsequent + # single-entity calls return +Skipped(reason: :db_inactive)+ + # until cleared. + # + # @param value [Boolean] + def db_inactive!(value: true) + @db_inactive = value + end + + # Queue a typed error to be returned (inside +Failed+) by the + # next call to the named method, then consumed. + # + # @param method [Symbol] e.g. +:report_host+ + # @param error [Msf::Reporting::Error] + def fail_next!(method, error) + @failures[method] << error + end + + # Reset all recorded calls and failure injections. Useful inside + # +before(:each)+ blocks when sharing a single backend instance. + def reset! + @calls.clear + @failures.clear + @next_row_id = 0 + @db_inactive = false + end + + ENTITY_TYPE_BY_METHOD.each do |method, entity_type| + define_method(method) do |**kwargs| + record_single(method, entity_type, kwargs) + end + end + + # Compound +report_session+ stub. Records the call and returns + # a +Compound+ whose steps mirror the documented order. Each + # inner step is recorded as a single-entity call too, so + # downstream parity tests can assert on either layer. + # + # @return [Msf::Reporting::Results::Compound] + def report_session(host:, service:, session:, vuln: nil, exploit_attempt: nil, raise_on_failure: true) + @calls << { + entity_type: :session, + method: :report_session, + kwargs: { host: host, service: service, session: session, vuln: vuln, exploit_attempt: exploit_attempt, raise_on_failure: raise_on_failure }.freeze + } + + steps = [] + steps << record_single(:report_host, :host, host) if host + steps << record_single(:report_service, :service, service) if service + steps << simulated_session_step(session) + steps << record_single(:report_vuln, :vuln, vuln) if vuln + steps << record_single(:report_exploit_attempt, :exploit_attempt, exploit_attempt) if exploit_attempt + + overall = if @db_inactive + :skipped_db_inactive + elsif steps.any?(&:failed?) + steps.any?(&:persisted?) ? :partial : :failed + else + :ok + end + + result = Results::Compound.new(steps: steps, overall: overall) + + if raise_on_failure && %i[partial failed].include?(overall) + raise CompoundError, result + end + + result + end + + private + + def record_single(method, entity_type, kwargs) + frozen_kwargs = (kwargs || {}).dup.freeze + @calls << { entity_type: entity_type, method: method, kwargs: frozen_kwargs } + + if @db_inactive + return Results::Skipped.new(entity_type: entity_type, reason: :db_inactive) + end + + if (queued = @failures[method]) && !queued.empty? + return Results::Failed.new(entity_type: entity_type, error: queued.shift) + end + + Results::Persisted.new(entity_type: entity_type, row_id: next_row_id, row: nil, touched: false) + end + + def simulated_session_step(session) + @calls << { entity_type: :session, method: :persist_session, kwargs: { session: session }.freeze } + if @db_inactive + Results::Skipped.new(entity_type: :session, reason: :db_inactive) + else + Results::Persisted.new(entity_type: :session, row_id: next_row_id, row: nil, touched: false) + end + end + + def next_row_id + @next_row_id += 1 + end + end + end + end +end diff --git a/lib/msf/core/reporting/connection_pool.rb b/lib/msf/core/reporting/connection_pool.rb new file mode 100644 index 0000000000000..b41c58a6d66f4 --- /dev/null +++ b/lib/msf/core/reporting/connection_pool.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Msf + module Reporting + # Thin façade over +ActiveRecord::Base.connection_pool#with_connection+. + # Every reporter backend that touches the DB MUST go through this + # helper so the framework owns a single, audited connection-checkout + # policy. + # + # ActiveRecord's +with_connection+ already guarantees release of the + # checked-out connection on: + # * normal block exit + # * +return+ / +next+ / +break+ from inside the block + # * any exception raised from inside the block (including + # +Thread#raise+) + # + # We layer no additional +ensure+ semantics on top — that would risk + # double-releasing the connection. The value of this helper is the + # single, well-named call site so static analysis and audit work + # can find every connection checkout in the reporting subsystem. + # + # @example + # Msf::Reporting::ConnectionPool.with_connection do + # Mdm::Host.find_or_create_by!(...) + # end + module ConnectionPool + module_function + + # Yield while holding a checked-out AR connection. When ActiveRecord + # is not loaded (e.g. the in-memory test backend) the block is run + # directly so callers stay transport-agnostic. + # + # @yield with no arguments. + # @return [Object] the block's return value. + # @raise [LocalJumpError] when called without a block. + def with_connection(&block) + raise LocalJumpError, 'no block given (yield)' unless block + + if defined?(::ApplicationRecord) && ::ApplicationRecord.respond_to?(:connection_pool) + ::ApplicationRecord.connection_pool.with_connection(&block) + else + block.call + end + end + end + end +end diff --git a/lib/msf/core/reporting/current_execution.rb b/lib/msf/core/reporting/current_execution.rb new file mode 100644 index 0000000000000..3558934378852 --- /dev/null +++ b/lib/msf/core/reporting/current_execution.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Msf + module Reporting + # Thread-local holder for the currently-executing + # +Mdm::ModuleExecution+ row. Wired into +Msf::Simple::*+ so that + # writers can stamp +module_execution_id+ onto every artifact a + # module produces or touches. + # + # The holder is per-thread so that two modules running in parallel + # background jobs each see their own execution. Nested blocks save + # and restore the previous value, so a child module invoked from + # inside a parent module's execution context correctly observes the + # child for the duration of the inner block and the parent + # afterwards. + # + # @example + # execution = Msf::Reporting::Execution.start!(framework:, mod:, originating_ui: 'console') + # Msf::Reporting::CurrentExecution.with(execution) do + # mod.run + # end + module CurrentExecution + THREAD_KEY = :msf_reporting_current_execution + private_constant :THREAD_KEY + + module_function + + # The currently-active +Mdm::ModuleExecution+ for this thread, or + # +nil+ when none is set. + # + # @return [Object, nil] + def current + Thread.current[THREAD_KEY] + end + + # @return [Integer, nil] the +id+ of {#current}, or +nil+. + def id + execution = current + execution&.id if execution.respond_to?(:id) + end + + # Set the current execution for the duration of the block. The + # previous value (which may be +nil+) is restored on block exit, + # including via exception. + # + # @param execution [Object] the execution row, or any object that + # responds to +#id+. Pass +nil+ to explicitly clear inside the + # block. + # @yield with no arguments. + # @return [Object] the block's return value. + # @raise [LocalJumpError] when called without a block. + def with(execution) + raise LocalJumpError, 'no block given (yield)' unless block_given? + + previous = Thread.current[THREAD_KEY] + Thread.current[THREAD_KEY] = execution + begin + yield + ensure + Thread.current[THREAD_KEY] = previous + end + end + + # Clear the current-execution slot on the current thread. Primarily + # useful in tests and in the rare error path where a +with+ block + # cannot be used. + def clear + Thread.current[THREAD_KEY] = nil + end + end + end +end diff --git a/lib/msf/core/reporting/errors.rb b/lib/msf/core/reporting/errors.rb new file mode 100644 index 0000000000000..50d16ae41de29 --- /dev/null +++ b/lib/msf/core/reporting/errors.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +module Msf + module Reporting + # Base class of every reporting-layer error. Never raised directly. + # + # Subclasses split on intent: + # * +ValidationError+ — bad caller input; ALWAYS raised at the call + # site. + # * +CompoundError+ — partial / failed compound pipeline; raised + # unless +raise_on_failure: false+. + # * +DetachedSessionError+ — operation attempted on a session whose DB + # row failed to persist. + # * +BackendError+ — transport / persistence-layer failure; + # carried inside +Failed+, NEVER raised by single-entity methods. + class Error < StandardError; end + + # Raised when caller-supplied kwargs are missing, of the wrong type, not + # in an enumerated set, or in mutual conflict. + # + # @!attribute [r] field + # @return [Symbol, nil] the offending kwarg, when applicable. + # @!attribute [r] fields + # @return [Array, nil] the offending kwargs, for +:conflict+. + # @!attribute [r] reason + # @return [Symbol] one of +:missing+, +:type+, +:enum+, +:conflict+, + # +:deprecated+. + # @!attribute [r] expected + # @return [Object, nil] expected type, for +reason: :type+. + # @!attribute [r] got + # @return [Object, nil] supplied value, for +:type+ / +:enum+. + # @!attribute [r] allowed + # @return [Array, nil] allowed values, for +reason: :enum+. + class ValidationError < Error + attr_reader :field, :fields, :reason, :expected, :got, :allowed + + def initialize(message = nil, field: nil, fields: nil, reason: nil, expected: nil, got: nil, allowed: nil) + @field = field + @fields = fields + @reason = reason + @expected = expected + @got = got + @allowed = allowed + super(message || default_message) + end + + private + + def default_message + case @reason + when :missing + "missing required kwarg #{@field.inspect}" + when :type + "kwarg #{@field.inspect} expected #{@expected.inspect}, got #{@got.class}" + when :enum + "kwarg #{@field.inspect} not in #{@allowed.inspect} (got #{@got.inspect})" + when :conflict + "conflicting kwargs: #{Array(@fields).inspect}" + when :deprecated + "kwarg #{@field.inspect} is deprecated" + else + 'reporting validation failed' + end + end + end + + # Raised by compound pipeline calls when the overall outcome is + # +:partial+ or +:failed+ and the caller did not pass + # +raise_on_failure: false+. Carries the +Compound+ result whose + # +steps+ enumerate per-step typed errors. + # + # @!attribute [r] result + # @return [Msf::Reporting::Results::Compound] + class CompoundError < Error + attr_reader :result + + def initialize(result, message = nil) + @result = result + super(message || "compound reporting overall=#{@result&.overall.inspect}") + end + end + + # Raised when a reporting call (typically +report_session_event+) is + # made against a session whose +Mdm::Session+ row failed to persist. + # The in-memory session remains fully alive; the framework only + # refuses to record events against it. + class DetachedSessionError < Error; end + + # Base class for transport / persistence-layer failures. Carried + # inside +Failed+ results and never raised by single-entity methods. + class BackendError < Error; end + + # The DB layer is currently inactive. Returned (not raised) inside a + # +Skipped(reason: :db_inactive)+ result on the first call after the + # DB drops, and on every subsequent call until recovery. + class DbInactiveError < BackendError; end + + # The remote +RemoteDataService+ proxy could not be reached or + # returned a transport-layer error. + class RemoteServiceError < BackendError; end + end +end diff --git a/lib/msf/core/reporting/execution.rb b/lib/msf/core/reporting/execution.rb new file mode 100644 index 0000000000000..4078880b7f32b --- /dev/null +++ b/lib/msf/core/reporting/execution.rb @@ -0,0 +1,448 @@ +# frozen_string_literal: true + +module Msf + module Reporting + # Lifecycle helpers that create and finalize +Mdm::ModuleExecution+ + # rows around module runs. Wired into +Msf::Simple::*+ so the row + # exists for the duration of the run and is the parent association + # every artifact carries. + # + # @example + # execution = Msf::Reporting::Execution.start!( + # framework: framework, + # mod: mod, + # originating_ui: 'console' + # ) + # Msf::Reporting::CurrentExecution.with(execution) do + # mod.run + # Msf::Reporting::Execution.finalize!(execution, terminal_status: 'success') + # end + module Execution + KIND_RUN = 'run' + KIND_CHECK = 'check' + + TERMINAL_RUNNING = 'running' + TERMINAL_SUCCESS = 'success' + TERMINAL_NEUTRAL = 'neutral' + TERMINAL_EXPECTED_FAILURE = 'expected_failure' + TERMINAL_UNHANDLED_EXCEPTION = 'unhandled_exception' + + PHASE_SETUP = 'setup' + PHASE_CHECK = 'check' + PHASE_EXPLOIT = 'exploit' + PHASE_CLEANUP = 'cleanup' + PHASE_POST = 'post' + PHASE_RUN = 'run' + + # Cap on the size of a serialized backtrace stored in + # +Mdm::ModuleExecutionError#backtrace+. PostgreSQL +text+ is + # unbounded; this is a defensive ceiling so a runaway backtrace + # never blows up the connection. + MAX_BACKTRACE_BYTES = 64 * 1024 + + # Instance variable used to mark exceptions that have already + # been written to +module_execution_errors+ so the same exception + # is not recorded twice when it bubbles through both +fail_with+ + # and the surrounding +handle_exception+ / simple-wrapper rescue. + # @api private + RECORDED_EXCEPTION_IVAR = :@_msf_reporting_error_recorded + + # Module-instance ivar set by +Msf::Exploit#handle_exception+ + # whenever it captures an unmapped exception (i.e. anything that + # is not +Msf::Exploit::Failed+/+Complete+/+OptionValidateError+/ + # +::Interrupt+). +Msf::Simple::Exploit.finalize_exploit_execution+ + # consults it so the parent +Mdm::ModuleExecution+ row records + # +unhandled_exception+ instead of +expected_failure+ when an + # exploit raised an exception that the driver swallowed via + # +handle_exception+. + # @api private + UNHANDLED_EXCEPTION_IVAR = :@_msf_reporting_unhandled_exception + + module_function + + # Persist a new +Mdm::ModuleExecution+ row in the +running+ state. + # + # @param framework [Msf::Framework] required. + # @param mod [Msf::Module] required; the module about to run. + # @param originating_ui [String, Symbol] one of + # +Mdm::ModuleExecution::ORIGINATING_UIS+ (e.g. +'console'+, + # +'rpc'+, +'mcp'+). + # @param parent_execution_id [Integer, nil] FK to the parent row + # when this execution was spawned from another module. + # @param kind [String] one of +Mdm::ModuleExecution::KINDS+; + # defaults to +'run'+. Pass +'check'+ when wrapping + # +check_simple+. + # @param started_at [Time, nil] override for tests; defaults to + # +Time.now.utc+. + # @return [Mdm::ModuleExecution, nil] +nil+ when no workspace can + # be resolved or persistence fails. + def start!(framework:, mod:, originating_ui:, parent_execution_id: nil, kind: KIND_RUN, started_at: nil) + workspace = resolve_workspace(framework) + return nil if workspace.nil? + + attrs = { + workspace: workspace, + module_reference_name: module_reference_name_for(mod), + module_type: module_type_for(mod), + kind: kind.to_s, + options_snapshot: capture_options_snapshot(mod), + originating_ui: originating_ui.to_s, + originating_user_id: nil, + originating_token_ref: nil, + parent_execution_id: parent_execution_id, + started_at: started_at || Time.now.utc, + terminal_status: TERMINAL_RUNNING, + single_entity_failure_count: 0 + } + + Msf::Reporting::ConnectionPool.with_connection do + ::Mdm::ModuleExecution.create!(attrs) + end + rescue StandardError => e + wlog("Reporting: failed to create ModuleExecution for #{safe_refname(mod)}: #{e.class}: #{e.message}") + nil + end + + # Update a previously-started execution with its terminal status + # and end timestamp. + # + # @param execution [Mdm::ModuleExecution, nil] the row returned by + # {.start!}. A +nil+ execution is a no-op so that callers can + # wrap unconditionally. + # @param terminal_status [String] one of + # +Mdm::ModuleExecution::TERMINAL_STATUSES+ other than +'running'+. + # @param failure_reason [String, nil] +Msf::Module::Failure+ + # constant string when applicable. + # @param failure_message [String, nil] human-readable failure + # detail; stored verbatim. + # @param ended_at [Time, nil] override for tests; defaults to + # +Time.now.utc+. + # @return [Mdm::ModuleExecution, nil] the same execution passed in. + def finalize!(execution, terminal_status:, failure_reason: nil, failure_message: nil, ended_at: nil) + return nil if execution.nil? + + Msf::Reporting::ConnectionPool.with_connection do + execution.update!( + ended_at: ended_at || Time.now.utc, + terminal_status: terminal_status.to_s, + failure_reason: failure_reason, + failure_message: failure_message + ) + end + execution + rescue StandardError => e + id = execution.respond_to?(:id) ? execution.id : nil + wlog("Reporting: failed to finalize ModuleExecution ##{id}: #{e.class}: #{e.message}") + execution + end + + # Capture the module's datastore as a string-keyed hash. Persisted + # verbatim. + # + # @param mod [Msf::Module] + # @return [Hash{String=>Object}, nil] + def capture_options_snapshot(mod) + return nil unless mod.respond_to?(:datastore) && mod.datastore + + if mod.datastore.respond_to?(:to_h) + mod.datastore.to_h + else + {} + end + rescue StandardError => e + wlog("Reporting: failed to capture options_snapshot for #{safe_refname(mod)}: #{e.class}: #{e.message}") + nil + end + + # Map an +Msf::Exploit::CheckCode+ instance to a terminal_status + # string. + # + # @param check_code [Msf::Exploit::CheckCode, Object, nil] + # @return [String] one of {TERMINAL_SUCCESS}, {TERMINAL_NEUTRAL}. + def terminal_status_for_check_code(check_code) + code = check_code.respond_to?(:code) ? check_code.code : nil + case code + when 'vulnerable', 'appears' + TERMINAL_SUCCESS + else + # safe, detected, unknown, unsupported, or anything else + TERMINAL_NEUTRAL + end + end + + # Persist one +Mdm::ModuleExecutionError+ row attached to + # +execution+. The row captures either an + # unhandled exception or an explicit +fail_with+-driven failure; + # +exception+ and +failure_reason+ are independent and may both + # be supplied. + # + # A +nil+ execution or a persistence failure is silently swallowed + # (with a +wlog+) so the calling module is never destabilized by + # reporting. + # + # @param execution [Mdm::ModuleExecution, nil] + # @param lifecycle_phase [String, Symbol] one of + # +Mdm::ModuleExecutionError::LIFECYCLE_PHASES+; see {phase_for}. + # @param exception [Exception, nil] originating exception, when any. + # @param failure_reason [String, nil] +Msf::Module::Failure+ + # constant string for +fail_with+-driven failures. + # @param message [String, nil] override for the human-readable + # message; defaults to +exception.message+. + # @param occurred_at [Time, nil] timestamp; defaults to now. + # @return [Mdm::ModuleExecutionError, nil] the persisted row, or + # +nil+ when no row could be written. + def record_error!(execution, lifecycle_phase:, exception: nil, failure_reason: nil, message: nil, occurred_at: nil) + return nil if execution.nil? + + attrs = { + module_execution: execution, + lifecycle_phase: lifecycle_phase.to_s, + exception_class: exception&.class&.name, + message: message || exception&.message, + backtrace: truncate_backtrace_for_storage(exception&.backtrace), + failure_reason: failure_reason, + occurred_at: occurred_at || Time.now.utc + } + + Msf::Reporting::ConnectionPool.with_connection do + ::Mdm::ModuleExecutionError.create!(attrs) + end + rescue StandardError => e + id = execution.respond_to?(:id) ? execution.id : nil + wlog("Reporting: failed to record ModuleExecutionError for execution ##{id}: #{e.class}: #{e.message}") + nil + end + + # Capture +exception+ against the currently-running execution, + # deduplicating so the same exception object is only persisted + # once even when it bubbles through multiple rescue layers + # (e.g. +fail_with+ → +handle_exception+ → simple wrapper). + # + # @param mod [Msf::Module] + # @param exception [Exception] + # @param lifecycle_phase [String, Symbol, nil] override; defaults + # to {phase_for}. + # @param failure_reason [String, nil] + # @return [Mdm::ModuleExecutionError, nil] + def capture_exception!(mod, exception, lifecycle_phase: nil, failure_reason: nil) + return nil if exception.nil? + return nil if exception_recorded?(exception) + + execution = Msf::Reporting::CurrentExecution.current + return nil if execution.nil? + + phase = lifecycle_phase || phase_for(mod, execution: execution) + row = record_error!( + execution, + lifecycle_phase: phase, + exception: exception, + failure_reason: failure_reason + ) + mark_exception_recorded(exception) + row + end + + # Capture a +fail_with+-driven failure against the currently-running + # execution. Writes one row with +exception_class: nil+ (per + # data-model) and the supplied +failure_reason+ / +message+. + # + # @param mod [Msf::Module] + # @param failure_reason [String, nil] +Msf::Module::Failure+ + # constant string. + # @param message [String, nil] + # @param lifecycle_phase [String, Symbol, nil] override; defaults + # to {phase_for}. + # @return [Mdm::ModuleExecutionError, nil] + def record_failure!(mod, failure_reason: nil, message: nil, lifecycle_phase: nil) + execution = Msf::Reporting::CurrentExecution.current + return nil if execution.nil? + + phase = lifecycle_phase || phase_for(mod, execution: execution) + record_error!( + execution, + lifecycle_phase: phase, + failure_reason: failure_reason, + message: message + ) + end + + # Derive the +lifecycle_phase+ to attribute an error to. Resolution + # order: + # + # 1. {PHASE_CHECK} when the active execution's +kind+ is +check+. + # This covers both the standalone +check_simple+ entry point + # and +Msf::Exploit::Remote::AutoCheck+, which spawns a child + # +Mdm::ModuleExecution+ row with +kind: 'check'+ around its + # embedded +check+ call. + # 2. The module-type fallback: +exploit+ → {PHASE_EXPLOIT}; + # +post+ → {PHASE_POST}; everything else → {PHASE_RUN}. + # + # @param mod [Msf::Module] + # @param execution [Mdm::ModuleExecution, nil] + # @return [String] + def phase_for(mod, execution: nil) + kind = execution.respond_to?(:kind) ? execution.kind.to_s : nil + return PHASE_CHECK if kind == KIND_CHECK + + case module_type_for(mod) + when 'exploit' + PHASE_EXPLOIT + when 'post' + PHASE_POST + else + PHASE_RUN + end + end + + # Run +block+ around a +mod.cleanup+ invocation and capture any + # exception it raises against the currently-active + # +Mdm::ModuleExecution+ with +lifecycle_phase: 'cleanup'+, then + # re-raise. Used at the +mod.cleanup+ boundary in the simple + # wrappers so a failure inside cleanup lands in + # +module_execution_errors+ with the correct phase, even though + # the surrounding {phase_for} would otherwise resolve to the + # module-type fallback (e.g. +'exploit'+ or +'post'+). + # + # Deduplicates against the recorded-exception ivar, so an outer + # rescue chain calling {.capture_exception!} on the same + # exception object will not write a second row. + # + # No-op (other than yielding) when no execution is active. + # + # @param mod [Msf::Module] + # @yield with no arguments. + # @return [Object] the block's return value. + def with_phase_cleanup(mod) + yield + rescue ::Exception => e # rubocop:disable Lint/RescueException -- attribute setup/cleanup failures (including Interrupt) before the surrounding wrapper rescues + capture_exception!(mod, e, lifecycle_phase: Msf::Reporting::Execution::PHASE_CLEANUP) + raise + end + + # Run +block+ around a +mod.setup+ invocation and capture any + # exception it raises against the currently-active + # +Mdm::ModuleExecution+ with +lifecycle_phase: 'setup'+, then + # re-raise. Used at the +mod.setup+ boundary in the simple + # wrappers so a failure inside setup lands in + # +module_execution_errors+ with the correct phase, even though + # the surrounding {phase_for} would otherwise resolve to the + # module-type fallback (e.g. +'exploit'+ or +'post'+). + # + # Deduplicates against the recorded-exception ivar, so an outer + # rescue chain calling {.capture_exception!} on the same + # exception object will not write a second row. + # + # No-op (other than yielding) when no execution is active. + # + # @param mod [Msf::Module] + # @yield with no arguments. + # @return [Object] the block's return value. + def with_phase_setup(mod) + yield + rescue ::Exception => e # rubocop:disable Lint/RescueException -- attribute setup/cleanup failures (including Interrupt) before the surrounding wrapper rescues + capture_exception!(mod, e, lifecycle_phase: Msf::Reporting::Execution::PHASE_SETUP) + raise + end + + # Serialize a backtrace for storage in + # +Mdm::ModuleExecutionError#backtrace+, capped at +max_bytes+ to + # protect against pathological cases. + # + # @param backtrace [Array, String, nil] + # @param max_bytes [Integer] + # @return [String, nil] + def truncate_backtrace_for_storage(backtrace, max_bytes: MAX_BACKTRACE_BYTES) + return nil if backtrace.nil? + + joined = backtrace.is_a?(Array) ? backtrace.join("\n") : backtrace.to_s + return nil if joined.empty? + return joined if joined.bytesize <= max_bytes + + joined.byteslice(0, max_bytes) + end + + # @api private + def exception_recorded?(exception) + exception.instance_variable_defined?(RECORDED_EXCEPTION_IVAR) && + exception.instance_variable_get(RECORDED_EXCEPTION_IVAR) + rescue StandardError + false + end + + # @api private + def mark_exception_recorded(exception) + exception.instance_variable_set(RECORDED_EXCEPTION_IVAR, true) + rescue StandardError + nil + end + + # @api private + def mark_module_unhandled_exception(mod) + mod.instance_variable_set(UNHANDLED_EXCEPTION_IVAR, true) + rescue StandardError + nil + end + + # Clear the unhandled-exception flag on +mod+. Called at the + # start of each simple wrapper so the flag never leaks across + # consecutive runs that reuse the same module instance (e.g. + # +check_simple+ and +run_simple+ on +Auxiliary+ / +Post+ do + # not allocate a +replicant+ first). + # @api private + def clear_module_unhandled_exception(mod) + return if mod.nil? + + if mod.instance_variable_defined?(UNHANDLED_EXCEPTION_IVAR) + mod.remove_instance_variable(UNHANDLED_EXCEPTION_IVAR) + end + rescue StandardError + nil + end + + # @api private + def module_unhandled_exception?(mod) + return false if mod.nil? + + mod.instance_variable_defined?(UNHANDLED_EXCEPTION_IVAR) && + mod.instance_variable_get(UNHANDLED_EXCEPTION_IVAR) + rescue StandardError + false + end + + # @api private + def resolve_workspace(framework) + return nil if framework.nil? + return nil unless framework.respond_to?(:db) && framework.db + return nil unless framework.db.respond_to?(:active) && framework.db.active + return nil unless framework.db.respond_to?(:workspace) + + framework.db.workspace + rescue StandardError + nil + end + + # @api private + def module_reference_name_for(mod) + if mod.respond_to?(:fullname) && mod.fullname + mod.fullname + elsif mod.respond_to?(:refname) && mod.refname + mod.refname + else + mod.class.name.to_s + end + end + + # @api private + def module_type_for(mod) + mod.respond_to?(:type) ? mod.type.to_s : '' + end + + # @api private + def safe_refname(mod) + mod.respond_to?(:refname) ? mod.refname : mod.class.name + rescue StandardError + '' + end + end + end +end diff --git a/lib/msf/core/reporting/reporter.rb b/lib/msf/core/reporting/reporter.rb new file mode 100644 index 0000000000000..2857259cf616b --- /dev/null +++ b/lib/msf/core/reporting/reporter.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +module Msf + module Reporting + # Public reporter facade exposed to module authors as + # +framework.report+. This class is the single entry point for + # every reporting call; the actual persistence work is delegated to + # one of the backends in +Msf::Reporting::Backends::*+ resolved at + # construction time from +framework.db.driver+. + # + # Single-entity methods that have not yet been migrated to a + # concrete backend return +Skipped(reason: :not_implemented)+. + class Reporter + # Backend driver enum. Drivers added by future features MUST be + # registered here before the reporter will load them. + KNOWN_DRIVERS = %i[db_manager http in_memory].freeze + + # Maps the legacy +framework.db.driver+ values (which today are + # strings such as +'postgresql'+ and +'http'+) onto the reporter's + # driver enum. Symbols already in +KNOWN_DRIVERS+ pass through + # untouched. + DRIVER_ALIASES = { + 'postgresql' => :db_manager, + :postgresql => :db_manager, + 'http' => :http, + 'in_memory' => :in_memory + }.freeze + + # @return [Msf::Framework, nil] + attr_reader :framework + + # @return [Mdm::Workspace, Object, nil] + attr_reader :workspace + + # @return [Symbol] one of +KNOWN_DRIVERS+. + attr_reader :driver + + # @return [Object] the resolved backend instance (an instance of + # one of +Msf::Reporting::Backends::*+, or +nil+ for drivers + # whose backend has not yet been implemented — currently + # +:http+). + attr_reader :backend + + # @param framework [Msf::Framework, nil] host framework. + # @param workspace [Mdm::Workspace, Object, nil] active workspace. + # @param driver [Symbol, String, nil] explicit backend selector; + # defaults to +framework.db.driver+ when +framework+ is given, + # then to +:in_memory+. + # @param backend [Object, nil] preconstructed backend instance, + # primarily used by tests to inject a fake. + def initialize(framework: nil, workspace: nil, driver: nil, backend: nil) + @framework = framework + @workspace = workspace + @driver = resolve_driver(driver) + @backend = backend || build_backend + end + + # @return [Mdm::ModuleExecution, nil] the execution row tied to the + # current thread. + def current_execution + nil + end + + # @return [Msf::Reporting::Results::Persisted, + # Msf::Reporting::Results::Skipped, + # Msf::Reporting::Results::Failed] + def report_host(**kwargs) + return not_implemented(:host) if @backend.nil? + + @backend.report_host(**kwargs) + end + + # @return [Msf::Reporting::Results::Skipped] stub. + def report_service(**_kwargs) + not_implemented(:service) + end + + # @return [Msf::Reporting::Results::Skipped] stub. + def report_vuln(**_kwargs) + not_implemented(:vuln) + end + + # @return [Msf::Reporting::Results::Skipped] stub. + def report_note(**_kwargs) + not_implemented(:note) + end + + # @return [Msf::Reporting::Results::Skipped] stub. + def report_loot(**_kwargs) + not_implemented(:loot) + end + + # @return [Msf::Reporting::Results::Compound] stub returning a + # +:skipped_db_inactive+ compound with no steps. + def report_session(**_kwargs) + Results::Compound.new(steps: [], overall: :skipped_db_inactive) + end + + private + + def resolve_driver(explicit) + candidate = explicit + if candidate.nil? && @framework && @framework.respond_to?(:db) && @framework.db.respond_to?(:driver) + candidate = @framework.db.driver + end + candidate = DRIVER_ALIASES.fetch(candidate, candidate) if candidate + candidate ||= :in_memory + candidate = candidate.to_sym if candidate.is_a?(String) + + unless KNOWN_DRIVERS.include?(candidate) + raise Error, "unknown reporting driver #{candidate.inspect}; expected one of #{KNOWN_DRIVERS.inspect}" + end + + candidate + end + + def build_backend + case @driver + when :db_manager + Backends::DbManagerBackend.new(framework: @framework) + when :in_memory + Backends::InMemoryBackend.new + when :http + # No remote backend is wired up yet; the reporter falls through + # to +Skipped(:not_implemented)+ for this driver. + nil + end + end + + def not_implemented(entity_type) + Results::Skipped.new(entity_type: entity_type, reason: :not_implemented) + end + end + end +end diff --git a/lib/msf/core/reporting/results.rb b/lib/msf/core/reporting/results.rb new file mode 100644 index 0000000000000..8b28f3644c092 --- /dev/null +++ b/lib/msf/core/reporting/results.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +module Msf + module Reporting + # Typed result objects returned by every reporting method. + module Results + # A single-entity reporting call that successfully resolved to a + # persisted database row (either newly created or located via + # find-or-create). + # + # @!attribute [r] entity_type + # @return [Symbol] the kind of entity persisted (e.g. +:host+, + # +:service+, +:vuln+, +:session+). + # @!attribute [r] row_id + # @return [Integer] the primary-key id of the persisted row. + # @!attribute [r] row + # @return [ActiveRecord::Base, Object, nil] the model instance, when + # available. Backends that cannot return a hydrated row (e.g. the + # in-memory test double or remote backend) MAY pass +nil+. + # @!attribute [r] touched + # @return [Boolean] true when the row pre-existed and a touched-by + # link was recorded; false when the row was newly created. + Persisted = Struct.new(:entity_type, :row_id, :row, :touched, keyword_init: true) do + def initialize(entity_type:, row_id:, row: nil, touched: false) + super + end + + def persisted? + true + end + + def skipped? + false + end + + def failed? + false + end + end + + # A single-entity reporting call that was intentionally not executed + # because a precondition was not satisfied. + # + # The canonical reason is +:db_inactive+; future reasons are added as + # the surface grows. + # + # @!attribute [r] entity_type + # @return [Symbol] + # @!attribute [r] reason + # @return [Symbol] + Skipped = Struct.new(:entity_type, :reason, keyword_init: true) do + def persisted? + false + end + + def skipped? + true + end + + def failed? + false + end + end + + # A single-entity reporting call that attempted to persist but could + # not. Carries a typed +Msf::Reporting::Error+ subclass; this object + # is RETURNED, not raised. + # + # @!attribute [r] entity_type + # @return [Symbol] + # @!attribute [r] error + # @return [Msf::Reporting::Error] + Failed = Struct.new(:entity_type, :error, keyword_init: true) do + def persisted? + false + end + + def skipped? + false + end + + def failed? + true + end + end + + # A compound-pipeline step that was not executed because a required + # parent step did not persist. Used inside +Compound#steps+ only. + # + # @!attribute [r] entity_type + # @return [Symbol] + # @!attribute [r] parent + # @return [Symbol] the parent step name that prevented execution. + SkippedDependencyFailed = Struct.new(:entity_type, :parent, keyword_init: true) do + def persisted? + false + end + + def skipped? + true + end + + def failed? + false + end + end + + # The return value of every compound reporting method. + # + # @!attribute [r] steps + # @return [Array] + # ordered list of step results. + # @!attribute [r] overall + # @return [Symbol] one of +:ok+, +:partial+, +:failed+, + # +:skipped_db_inactive+. + COMPOUND_OVERALL_VALUES = %i[ok partial failed skipped_db_inactive].freeze + + Compound = Struct.new(:steps, :overall, keyword_init: true) do + def initialize(steps:, overall:) + unless COMPOUND_OVERALL_VALUES.include?(overall) + raise ArgumentError, "overall must be one of #{COMPOUND_OVERALL_VALUES.inspect}, got #{overall.inspect}" + end + + super + end + + def ok? + overall == :ok + end + + def partial? + overall == :partial + end + + def failed? + overall == :failed + end + + def skipped_db_inactive? + overall == :skipped_db_inactive + end + end + end + end +end diff --git a/lib/msf_autoload.rb b/lib/msf_autoload.rb index 463e5a21d04d9..46104d301f4fb 100644 --- a/lib/msf_autoload.rb +++ b/lib/msf_autoload.rb @@ -67,7 +67,8 @@ def ignore_list "#{__dir__}/rex/proto/ssh/connection.rb", "#{__dir__}/rex/proto/kerberos/pac/krb5_pac.rb", "#{__dir__}/msf/core/mcp.rb", - "#{__dir__}/msf/core/mcp/" + "#{__dir__}/msf/core/mcp/", + "#{__dir__}/msf/core/reporting/errors.rb" ] end diff --git a/spec/lib/msf/base/simple/auxiliary_lifecycle_spec.rb b/spec/lib/msf/base/simple/auxiliary_lifecycle_spec.rb new file mode 100644 index 0000000000000..ae5b39f5384a6 --- /dev/null +++ b/spec/lib/msf/base/simple/auxiliary_lifecycle_spec.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Verifies that +Msf::Simple::Auxiliary.run_simple+ and +.check_simple+ +# bracket the module call with +Msf::Reporting::Execution.start!+ / +# +.finalize!+ and expose the execution via +# +Msf::Reporting::CurrentExecution+ for the duration of the run. +RSpec.describe 'Msf::Simple::Auxiliary lifecycle wiring' do + let(:events) { double('events', on_module_run: nil, on_module_complete: nil) } + let(:framework) { double('framework', events: events, db: double('db', active: false)) } + let(:execution) { double('execution', id: 11) } + + let(:mod_class) do + Class.new do + attr_accessor :error, :fail_reason, :fail_detail, :check_code, :last_vuln_attempt + attr_writer :user_input, :user_output + attr_reader :framework, :datastore + + def initialize(framework, type: 'auxiliary') + @framework = framework + @type = type + @datastore = { 'RHOSTS' => '192.0.2.10' } + @fail_reason = Msf::Module::Failure::None + end + + def type; @type; end + def fullname; 'auxiliary/scanner/test'; end + def refname; 'scanner/test'; end + def init_ui(_i, _o); end + def setup; end + def cleanup; end + def report_failure; end + def run; :ok; end + def check; Msf::Exploit::CheckCode::Vulnerable; end + def print_error(_msg); end + def print_status(_msg); end + end + end + + let(:mod) { mod_class.new(framework) } + let(:run_uuid) { 'uuid-test' } + let(:listener) { Msf::Simple::NoopJobListener.instance } + + describe 'run path' do + let(:ctx) do + [mod, run_uuid, listener, + { originating_ui: 'console', kind: Msf::Reporting::Execution::KIND_RUN }] + end + + it 'starts an execution, exposes it as current, and finalizes success' do + expect(Msf::Reporting::Execution).to receive(:start!).with( + hash_including( + framework: framework, + mod: mod, + originating_ui: 'console', + kind: 'run' + ) + ).and_return(execution) + + expect(mod).to receive(:run).and_wrap_original do |original| + expect(Msf::Reporting::CurrentExecution.current).to be(execution) + original.call + end + + expect(Msf::Reporting::Execution).to receive(:finalize!).with( + execution, + terminal_status: 'success', + failure_reason: nil, + failure_message: nil + ) + + Msf::Simple::Auxiliary.send(:job_run_proc, ctx, &:run) + expect(Msf::Reporting::CurrentExecution.current).to be_nil + end + + it 'finalizes as expected_failure when mod.fail_reason is set' do + mod.fail_reason = Msf::Module::Failure::NoTarget + mod.fail_detail = 'no target' + allow(Msf::Reporting::Execution).to receive(:start!).and_return(execution) + expect(Msf::Reporting::Execution).to receive(:finalize!).with( + execution, + terminal_status: 'expected_failure', + failure_reason: Msf::Module::Failure::NoTarget, + failure_message: 'no target' + ) + + Msf::Simple::Auxiliary.send(:job_run_proc, ctx, &:run) + end + + it 'finalizes as unhandled_exception when run! raises an unexpected error' do + allow(Msf::Reporting::Execution).to receive(:start!).and_return(execution) + allow(mod).to receive(:run).and_raise(RuntimeError, 'kaboom') + + expect(Msf::Reporting::Execution).to receive(:finalize!).with( + execution, + terminal_status: 'unhandled_exception', + failure_reason: Msf::Module::Failure::Unknown, + failure_message: 'kaboom' + ) + + Msf::Simple::Auxiliary.send(:job_run_proc, ctx, &:run) + end + end + + describe 'check path' do + let(:ctx) do + [mod, run_uuid, listener, + { originating_ui: 'console', kind: Msf::Reporting::Execution::KIND_CHECK }] + end + + it 'creates an execution with kind=check and maps Vulnerable -> success' do + expect(Msf::Reporting::Execution).to receive(:start!).with( + hash_including(kind: 'check') + ).and_return(execution) + expect(Msf::Reporting::Execution).to receive(:finalize!).with( + execution, + terminal_status: 'success', + failure_reason: nil, + failure_message: nil + ) + + Msf::Simple::Auxiliary.send(:job_run_proc, ctx) { |m| m.check } + end + + it 'maps Safe -> neutral' do + allow(mod).to receive(:check).and_return(Msf::Exploit::CheckCode::Safe) + allow(Msf::Reporting::Execution).to receive(:start!).and_return(execution) + expect(Msf::Reporting::Execution).to receive(:finalize!).with( + execution, + terminal_status: 'neutral', + failure_reason: nil, + failure_message: nil + ) + + Msf::Simple::Auxiliary.send(:job_run_proc, ctx) { |m| m.check } + end + + it 'maps a raised exception -> unhandled_exception' do + allow(mod).to receive(:check).and_raise(RuntimeError, 'check broke') + allow(Msf::Reporting::Execution).to receive(:start!).and_return(execution) + expect(Msf::Reporting::Execution).to receive(:finalize!).with( + execution, + terminal_status: 'unhandled_exception', + failure_reason: nil, + failure_message: 'check broke' + ) + + Msf::Simple::Auxiliary.send(:job_run_proc, ctx) { |m| m.check } + end + end +end diff --git a/spec/lib/msf/base/simple/exploit_lifecycle_spec.rb b/spec/lib/msf/base/simple/exploit_lifecycle_spec.rb new file mode 100644 index 0000000000000..fc93654afee0c --- /dev/null +++ b/spec/lib/msf/base/simple/exploit_lifecycle_spec.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Verifies +Msf::Simple::Exploit.finalize_exploit_execution+ and +# +.job_check_proc+ translate exploit post-run state to the correct +# +Mdm::ModuleExecution+ terminal_status, and that handler-style +# (passive) exploits intentionally remain in +running+ state. +RSpec.describe 'Msf::Simple::Exploit lifecycle wiring' do + let(:execution) { double('execution', id: 99) } + + describe '.finalize_exploit_execution' do + let(:exploit) do + double('exploit', + passive?: false, + fail_reason: Msf::Module::Failure::None, + fail_detail: nil, + error: nil) + end + + it 'finalizes success on clean runs' do + expect(Msf::Reporting::Execution).to receive(:finalize!).with( + execution, + terminal_status: 'success' + ) + Msf::Simple::Exploit.finalize_exploit_execution(execution, exploit) + end + + it 'finalizes expected_failure when fail_reason is set' do + allow(exploit).to receive(:fail_reason).and_return(Msf::Module::Failure::NoTarget) + allow(exploit).to receive(:fail_detail).and_return('detail') + expect(Msf::Reporting::Execution).to receive(:finalize!).with( + execution, + terminal_status: 'expected_failure', + failure_reason: Msf::Module::Failure::NoTarget, + failure_message: 'detail' + ) + Msf::Simple::Exploit.finalize_exploit_execution(execution, exploit) + end + + # Handler-style exploits keep their execution row in 'running' + # state forever. + it 'leaves passive (handler) exploits in running state' do + allow(exploit).to receive(:passive?).and_return(true) + expect(Msf::Reporting::Execution).not_to receive(:finalize!) + Msf::Simple::Exploit.finalize_exploit_execution(execution, exploit) + end + + # The module-instance IVAR is set by either +exploit_simple+'s + # outer rescue (for exceptions that bubble all the way up) or + # +Msf::Exploit#handle_exception+ (for exceptions that + # +Msf::ExploitDriver#job_run_proc+ caught and swallowed). + it 'finalizes unhandled_exception when the module is flagged via the IVAR' do + err = double('error', message: 'kaboom') + allow(exploit).to receive(:error).and_return(err) + allow(exploit).to receive(:fail_reason).and_return(Msf::Module::Failure::Unknown) + Msf::Reporting::Execution.mark_module_unhandled_exception(exploit) + + expect(Msf::Reporting::Execution).to receive(:finalize!).with( + execution, + terminal_status: 'unhandled_exception', + failure_reason: Msf::Module::Failure::Unknown, + failure_message: 'kaboom' + ) + Msf::Simple::Exploit.finalize_exploit_execution(execution, exploit) + end + end + + describe '.job_check_proc' do + let(:framework) { double('framework', db: double('db', active: false)) } + let(:mod_class) do + Class.new do + attr_reader :framework, :datastore + attr_accessor :error + + def initialize(framework) + @framework = framework + @datastore = {} + end + + def type; 'exploit'; end + def fullname; 'exploit/windows/test'; end + def refname; 'windows/test'; end + def setup; end + def cleanup; end + def check; Msf::Exploit::CheckCode::Vulnerable; end + def handle_exception(_e); end + end + end + let(:mod) { mod_class.new(framework) } + let(:listener) { Msf::Simple::NoopJobListener.instance } + let(:ctx) { [mod, 'uuid', listener, { originating_ui: 'console' }] } + + it 'creates an execution with kind=check and maps Vulnerable -> success' do + expect(Msf::Reporting::Execution).to receive(:start!).with( + hash_including(kind: 'check') + ).and_return(execution) + expect(Msf::Reporting::Execution).to receive(:finalize!).with( + execution, + terminal_status: 'success', + failure_reason: nil, + failure_message: nil + ) + + Msf::Simple::Exploit.send(:job_check_proc, ctx) + end + + it 'maps a raised check exception to unhandled_exception' do + err = RuntimeError.new('check failure') + allow(mod).to receive(:check).and_raise(err) + allow(mod).to receive(:handle_exception) { |e| mod.error = e } + allow(Msf::Reporting::Execution).to receive(:start!).and_return(execution) + expect(Msf::Reporting::Execution).to receive(:finalize!).with( + execution, + terminal_status: 'unhandled_exception', + failure_reason: nil, + failure_message: 'check failure' + ) + + Msf::Simple::Exploit.send(:job_check_proc, ctx) + end + + # Heads-up: check methods are contracted to return a CheckCode and + # never raise. This covers the defensive path for modules that + # call fail_with from check anyway. + it 'maps a fail_with inside check to expected_failure with typed reason and detail' do + mod_class.class_eval do + attr_accessor :fail_reason, :fail_detail + def check + self.fail_reason = Msf::Module::Failure::NoTarget + self.fail_detail = 'detail from check' + raise Msf::Exploit::Failed, 'detail from check' + end + end + allow(mod).to receive(:handle_exception) { |e| mod.error = e } + allow(Msf::Reporting::Execution).to receive(:start!).and_return(execution) + expect(Msf::Reporting::Execution).to receive(:finalize!).with( + execution, + terminal_status: 'expected_failure', + failure_reason: Msf::Module::Failure::NoTarget, + failure_message: 'detail from check' + ) + + Msf::Simple::Exploit.send(:job_check_proc, ctx) + end + + # A setup exception in the standalone-check path must be + # captured against the kind=check execution with phase 'setup', + # not silently swallowed at the lifecycle boundary. + it 'wraps mod.setup so a setup exception is captured with phase=setup' do + allow(mod).to receive(:setup).and_raise(StandardError, 'setup boom') + allow(mod).to receive(:handle_exception) { |e| mod.error = e } + allow(Msf::Reporting::Execution).to receive(:start!).and_return(execution) + allow(Msf::Reporting::Execution).to receive(:finalize!) + + expect(::Mdm::ModuleExecutionError).to receive(:create!).with( + hash_including( + module_execution: execution, + lifecycle_phase: 'setup', + exception_class: 'StandardError' + ) + ) + + Msf::Simple::Exploit.send(:job_check_proc, ctx) + end + + # A cleanup exception must be captured with phase 'cleanup'. + # The check itself completed successfully so the surrounding + # +ensure+ runs while CurrentExecution is still bound to the + # kind=check row. + it 'wraps mod.cleanup so a cleanup exception is captured with phase=cleanup' do + allow(mod).to receive(:cleanup).and_raise(StandardError, 'cleanup boom') + allow(Msf::Reporting::Execution).to receive(:start!).and_return(execution) + allow(Msf::Reporting::Execution).to receive(:finalize!) + + expect(::Mdm::ModuleExecutionError).to receive(:create!).with( + hash_including( + module_execution: execution, + lifecycle_phase: 'cleanup', + exception_class: 'StandardError' + ) + ) + + expect { Msf::Simple::Exploit.send(:job_check_proc, ctx) }.to raise_error(StandardError, 'cleanup boom') + end + end +end diff --git a/spec/lib/msf/base/simple/post_lifecycle_spec.rb b/spec/lib/msf/base/simple/post_lifecycle_spec.rb new file mode 100644 index 0000000000000..602a1406619c6 --- /dev/null +++ b/spec/lib/msf/base/simple/post_lifecycle_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Verifies +Msf::Simple::Post.job_run_proc+ wires the execution +# lifecycle around post-module runs. +RSpec.describe 'Msf::Simple::Post lifecycle wiring' do + let(:sessions) { double('sessions', get: double('session')) } + let(:events) do + double('events', + on_module_run: nil, + on_module_complete: nil, + on_session_module_run: nil) + end + let(:framework) do + double('framework', + events: events, + sessions: sessions, + db: double('db', active: false)) + end + let(:execution) { double('execution', id: 7) } + + let(:mod_class) do + Class.new do + attr_accessor :error + attr_reader :framework, :datastore + + def initialize(framework) + @framework = framework + @datastore = { 'SESSION' => 1 } + end + + def type; 'post'; end + def fullname; 'post/multi/general/execute'; end + def refname; 'multi/general/execute'; end + def setup; end + def cleanup; end + def run; :ok; end + def print_error(_msg); end + end + end + + let(:mod) { mod_class.new(framework) } + let(:ctx) { [mod, { originating_ui: 'console' }] } + + it 'starts an execution, exposes it as current, and finalizes success' do + expect(Msf::Reporting::Execution).to receive(:start!).with( + hash_including(framework: framework, mod: mod, originating_ui: 'console', kind: 'run') + ).and_return(execution) + expect(mod).to receive(:run).and_wrap_original do |original| + expect(Msf::Reporting::CurrentExecution.current).to be(execution) + original.call + end + expect(Msf::Reporting::Execution).to receive(:finalize!).with( + execution, + terminal_status: 'success', + failure_reason: nil, + failure_message: nil + ) + + Msf::Simple::Post.send(:job_run_proc, ctx) + end + + it 'finalizes as unhandled_exception when run raises' do + allow(Msf::Reporting::Execution).to receive(:start!).and_return(execution) + allow(mod).to receive(:run).and_raise(RuntimeError, 'boom') + + expect(Msf::Reporting::Execution).to receive(:finalize!).with( + execution, + terminal_status: 'unhandled_exception', + failure_reason: nil, + failure_message: 'boom' + ) + + Msf::Simple::Post.send(:job_run_proc, ctx) + end +end diff --git a/spec/lib/msf/core/auxiliary/scanner_execution_propagation_spec.rb b/spec/lib/msf/core/auxiliary/scanner_execution_propagation_spec.rb new file mode 100644 index 0000000000000..dd1c244fc4b84 --- /dev/null +++ b/spec/lib/msf/core/auxiliary/scanner_execution_propagation_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Msf::Auxiliary::Scanner spawns one thread per host (or per batch). +# The parent +CurrentExecution+ must propagate into those threads so +# that +fail_with+-driven failures from within +run_host+ / +run_batch+ +# are attached to the parent execution row. +# +# The full +Scanner#run+ method is intricate (RhostsWalker, replicant, +# Queue plumbing, progress reporting), so this spec verifies the +# propagation pattern at the behavioral level: a parent execution is +# captured in the enclosing thread, then re-entered with +# +CurrentExecution.with(parent_execution)+ inside a spawned thread, +# so the inner code sees the same execution. +RSpec.describe Msf::Auxiliary::Scanner, 'execution propagation pattern' do + describe 'CurrentExecution + Thread propagation primitive' do + let(:execution) { double('Mdm::ModuleExecution', kind: 'run') } + + it 'captures parent_execution outside the spawned thread and re-enters CurrentExecution.with inside it' do + observed = nil + Msf::Reporting::CurrentExecution.with(execution) do + parent_execution = Msf::Reporting::CurrentExecution.current + thread = Thread.new do + Msf::Reporting::CurrentExecution.with(parent_execution) do + observed = Msf::Reporting::CurrentExecution.current + end + end + thread.join + end + expect(observed).to be(execution) + end + + it 'does NOT see the parent execution if the thread does not re-enter' do + observed = :unset + Msf::Reporting::CurrentExecution.with(execution) do + thread = Thread.new do + observed = Msf::Reporting::CurrentExecution.current + end + thread.join + end + expect(observed).to be_nil + end + end +end diff --git a/spec/lib/msf/core/exploit/remote/auto_check_phase_spec.rb b/spec/lib/msf/core/exploit/remote/auto_check_phase_spec.rb new file mode 100644 index 0000000000000..360274ff35e3b --- /dev/null +++ b/spec/lib/msf/core/exploit/remote/auto_check_phase_spec.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# +Msf::Exploit::Remote::AutoCheck+ runs its embedded +check+ call +# under a child +Mdm::ModuleExecution+ row with +kind: 'check'+ that +# is parented to the surrounding exploit's run row. Inside that +# bracket, +Msf::Reporting::Execution.phase_for+ resolves to +# +PHASE_CHECK+ via the +kind == 'check'+ branch. +RSpec.describe Msf::Exploit::Remote::AutoCheck, 'phase signaling via check execution' do + let(:parent_execution) { double('Mdm::ModuleExecution', id: 100, kind: 'run') } + let(:check_execution) { double('Mdm::ModuleExecution', id: 200, kind: 'check') } + + let(:mock_module) do + auto_check = described_class + Class.new(::Msf::Exploit) do + prepend auto_check + attr_accessor :autocheck_observed_kind, :autocheck_observed_phase + + define_method(:check) do + active = Msf::Reporting::CurrentExecution.current + self.autocheck_observed_kind = active&.kind + self.autocheck_observed_phase = Msf::Reporting::Execution.phase_for(self, execution: active) + ::Msf::Exploit::CheckCode::Vulnerable + end + + def run + # noop + end + end + end + + subject(:mod) { mock_module.new } + + before do + allow(Msf::Reporting::Execution).to receive(:start!).and_return(check_execution) + allow(Msf::Reporting::Execution).to receive(:finalize!) + end + + it 'switches CurrentExecution to the kind=check row while check is running' do + Msf::Reporting::CurrentExecution.with(parent_execution) do + mod.run + end + + expect(mod.autocheck_observed_kind).to eq('check') + expect(mod.autocheck_observed_phase).to eq(Msf::Reporting::Execution::PHASE_CHECK) + end + + it 'restores the parent execution after check returns' do + Msf::Reporting::CurrentExecution.with(parent_execution) do + mod.run + expect(Msf::Reporting::CurrentExecution.current).to eq(parent_execution) + end + end + + it 'starts the child execution with parent_execution_id set' do + expect(Msf::Reporting::Execution).to receive(:start!).with( + hash_including( + parent_execution_id: parent_execution.id, + kind: Msf::Reporting::Execution::KIND_CHECK, + originating_ui: 'autocheck' + ) + ).and_return(check_execution) + + Msf::Reporting::CurrentExecution.with(parent_execution) do + mod.run + end + end + + it 'finalizes the check execution with terminal_status derived from the CheckCode' do + expect(Msf::Reporting::Execution).to receive(:finalize!).with( + check_execution, + hash_including(terminal_status: Msf::Reporting::Execution::TERMINAL_SUCCESS) + ) + + Msf::Reporting::CurrentExecution.with(parent_execution) do + mod.run + end + end + + # The kind=check row produced by autocheck mirrors the kind=check + # row produced by the standalone +Msf::Simple::Exploit.job_check_proc+ + # path when +check+ raises -- both must populate +failure_message+ + # so dashboards can show the raised message. + context 'when check raises an unhandled exception' do + let(:mock_module) do + auto_check = described_class + Class.new(::Msf::Exploit) do + prepend auto_check + + def check + raise RuntimeError, 'check kaboom' + end + + def run + # noop + end + end + end + + subject(:mod) { mock_module.new } + + it 'finalizes the kind=check row with terminal_status=unhandled_exception and failure_message' do + expect(Msf::Reporting::Execution).to receive(:finalize!).with( + check_execution, + terminal_status: Msf::Reporting::Execution::TERMINAL_UNHANDLED_EXCEPTION, + failure_reason: nil, + failure_message: 'check kaboom' + ) + + expect do + Msf::Reporting::CurrentExecution.with(parent_execution) do + mod.run + end + end.to raise_error(RuntimeError, 'check kaboom') + end + end + + # Heads-up: check methods are contracted to return a CheckCode and + # never raise. This covers the defensive path for modules that call + # fail_with from check anyway. + context 'when check calls fail_with' do + let(:mock_module) do + auto_check = described_class + Class.new(::Msf::Exploit) do + prepend auto_check + + def check + fail_with(Msf::Module::Failure::NoTarget, 'Unexpected fail_with') + end + + def run + # noop + end + end + end + + subject(:mod) { mock_module.new } + + it 'finalizes the kind=check row with terminal_status=expected_failure, the typed failure_reason, and fail_detail' do + expect(Msf::Reporting::Execution).to receive(:finalize!).with( + check_execution, + terminal_status: Msf::Reporting::Execution::TERMINAL_EXPECTED_FAILURE, + failure_reason: Msf::Module::Failure::NoTarget, + failure_message: 'Unexpected fail_with' + ) + + expect do + Msf::Reporting::CurrentExecution.with(parent_execution) do + mod.run + end + end.to raise_error(Msf::Exploit::Failed) + end + end +end diff --git a/spec/lib/msf/core/module/fail_with_capture_spec.rb b/spec/lib/msf/core/module/fail_with_capture_spec.rb new file mode 100644 index 0000000000000..fd5d44ec53a91 --- /dev/null +++ b/spec/lib/msf/core/module/fail_with_capture_spec.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Every +fail_with+ override records a +Mdm::ModuleExecutionError+ +# row via +Execution.record_failure!+ and marks the raised exception +# so the surrounding +handle_exception+ / simple-wrapper rescue does +# not write a second row. +RSpec.describe 'Msf::Module#fail_with reporting integration' do + let(:execution) { double('Mdm::ModuleExecution', id: 11, kind: 'run') } + + before do + allow(Msf::Reporting::CurrentExecution).to receive(:current).and_return(execution) + end + + # Build a barebones instance of +klass+ via +.allocate+ so the + # parent +Msf::Module+ initializer (which requires a framework) is + # bypassed. The module's own +fail_with+ method is exercised + # directly. + def allocate_module(klass) + mod = klass.allocate + mod.instance_variable_set(:@fail_reason, Msf::Module::Failure::None) + mod.instance_variable_set(:@fail_detail, nil) + mod + end + + shared_examples 'a fail_with override that records and marks the failure' do |raised_class| + it "records the failure_reason and message before raising #{raised_class}" do + reason = Msf::Module::Failure::NoAccess + message = 'Unable to log in' + + expect(Msf::Reporting::Execution).to receive(:record_failure!).with( + mod_instance, + failure_reason: reason, + message: message + ) + + raised = nil + begin + mod_instance.fail_with(reason, message) + rescue raised_class => e + raised = e + end + + expect(raised).to be_a(raised_class) + expect(Msf::Reporting::Execution.exception_recorded?(raised)).to be(true) + end + end + + describe 'Msf::Exploit#fail_with' do + let(:mod_instance) { allocate_module(Msf::Exploit) } + + include_examples 'a fail_with override that records and marks the failure', + Msf::Exploit::Failed + end + + describe 'Msf::Auxiliary#fail_with' do + let(:mod_instance) { allocate_module(Msf::Auxiliary) } + + include_examples 'a fail_with override that records and marks the failure', + Msf::Auxiliary::Failed + end + + describe 'Msf::Post#fail_with' do + let(:mod_instance) { allocate_module(Msf::Post) } + + include_examples 'a fail_with override that records and marks the failure', + Msf::Post::Failed + end + + describe 'Msf::Evasion#fail_with' do + let(:mod_instance) { allocate_module(Msf::Evasion) } + + include_examples 'a fail_with override that records and marks the failure', + Msf::Evasion::Failed + end + + describe 'Msf::Auxiliary::Scanner#fail_with' do + let(:scanner) do + klass = Class.new(Msf::Auxiliary) do + include Msf::Auxiliary::Scanner + end + allocate_module(klass) + end + + it 'records the failure and raises AttemptFailed by default' do + expect(Msf::Reporting::Execution).to receive(:record_failure!).with( + scanner, + failure_reason: Msf::Module::Failure::Unreachable, + message: 'down' + ) + + raised = nil + begin + scanner.fail_with(Msf::Module::Failure::Unreachable, 'down') + rescue Msf::Auxiliary::Scanner::AttemptFailed => e + raised = e + end + + expect(raised).to be_a(Msf::Auxiliary::Scanner::AttemptFailed) + expect(Msf::Reporting::Execution.exception_recorded?(raised)).to be(true) + end + + it 'records the failure and raises Failed when abort: true' do + expect(Msf::Reporting::Execution).to receive(:record_failure!).with( + scanner, + failure_reason: Msf::Module::Failure::Unreachable, + message: 'down' + ) + + raised = nil + begin + scanner.fail_with(Msf::Module::Failure::Unreachable, 'down', abort: true) + rescue Msf::Auxiliary::Failed => e + raised = e + end + + expect(raised).to be_a(Msf::Auxiliary::Failed) + expect(Msf::Reporting::Execution.exception_recorded?(raised)).to be(true) + end + end + + describe 'Msf::Module#fail_with (base)' do + let(:base_mod) { allocate_module(Msf::Module) } + + it 'records the failure and raises RuntimeError' do + expect(Msf::Reporting::Execution).to receive(:record_failure!).with( + base_mod, + failure_reason: 'no-access', + message: 'denied' + ) + + raised = nil + begin + base_mod.fail_with('no-access', 'denied') + rescue RuntimeError => e + raised = e + end + + expect(raised).to be_a(RuntimeError) + expect(Msf::Reporting::Execution.exception_recorded?(raised)).to be(true) + end + end + + describe 'Msf::Exploit#handle_exception with a fail_with-raised Failed' do + let(:exploit) do + mod = allocate_module(Msf::Exploit) + mod.instance_variable_set( + :@framework, + double('framework', events: double('events', on_module_error: nil)) + ) + allow(mod).to receive(:framework).and_return(mod.instance_variable_get(:@framework)) + allow(mod).to receive(:refname).and_return('windows/test') + allow(mod).to receive(:print_error) + allow(mod).to receive(:report_failure) + allow(mod).to receive(:interrupt_handler) + allow(mod).to receive(:elog) + mod + end + + it 'does not invoke capture_exception! when fail_with raised the exception' do + err = Msf::Exploit::Failed.new('login failed') + Msf::Reporting::Execution.mark_exception_recorded(err) + exploit.fail_reason = Msf::Module::Failure::NoAccess + + expect(Msf::Reporting::Execution).not_to receive(:capture_exception!) + exploit.handle_exception(err) + end + end +end diff --git a/spec/lib/msf/core/module/handle_exception_capture_spec.rb b/spec/lib/msf/core/module/handle_exception_capture_spec.rb new file mode 100644 index 0000000000000..b87dde024c9c0 --- /dev/null +++ b/spec/lib/msf/core/module/handle_exception_capture_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Unhandled exceptions raised during a module run land in +# +module_execution_errors+ via the live +record_error!+ codepath. +# Specs assert against +::Mdm::ModuleExecutionError.create!+ rather +# than stubbing +capture_exception!+ itself, so that thread-local +# context (CurrentExecution + phase resolution) is exercised +# end-to-end. +RSpec.describe 'Msf::Reporting::Execution capture integration' do + let(:execution) { double('Mdm::ModuleExecution', id: 7, kind: 'run') } + let(:check_execution) { double('Mdm::ModuleExecution', id: 8, kind: 'check') } + + describe 'Msf::Exploit#handle_exception inside CurrentExecution.with' do + let(:exploit) do + mod = Msf::Exploit.allocate + mod.instance_variable_set(:@fail_reason, Msf::Module::Failure::None) + mod.instance_variable_set(:@fail_detail, nil) + mod.instance_variable_set( + :@framework, + double('framework', events: double('events', on_module_error: nil)) + ) + allow(mod).to receive(:framework).and_return(mod.instance_variable_get(:@framework)) + allow(mod).to receive(:refname).and_return('windows/test') + allow(mod).to receive(:type).and_return('exploit') + allow(mod).to receive(:print_error) + allow(mod).to receive(:report_failure) + allow(mod).to receive(:interrupt_handler) + allow(mod).to receive(:elog) + mod + end + + it 'records an Mdm::ModuleExecutionError with phase exploit' do + err = Rex::ConnectionError.new('refused') + + expect(::Mdm::ModuleExecutionError).to receive(:create!).with( + hash_including( + module_execution: execution, + lifecycle_phase: 'exploit', + exception_class: 'Rex::ConnectionError', + failure_reason: Msf::Exploit::Failure::Unreachable + ) + ) + + Msf::Reporting::CurrentExecution.with(execution) do + exploit.handle_exception(err) + end + end + + it 'records phase check when execution kind is check' do + err = RuntimeError.new('boom') + + expect(::Mdm::ModuleExecutionError).to receive(:create!).with( + hash_including(lifecycle_phase: 'check', exception_class: 'RuntimeError') + ) + + Msf::Reporting::CurrentExecution.with(check_execution) do + exploit.handle_exception(err) + end + end + + it 'does not record Msf::Exploit::Failed (already persisted by fail_with)' do + err = Msf::Exploit::Failed.new('login failed') + expect(::Mdm::ModuleExecutionError).not_to receive(:create!) + + Msf::Reporting::CurrentExecution.with(execution) do + exploit.handle_exception(err) + end + end + + it 'does not record an exception that has already been marked' do + err = StandardError.new('boom') + Msf::Reporting::Execution.mark_exception_recorded(err) + + expect(::Mdm::ModuleExecutionError).not_to receive(:create!) + + Msf::Reporting::CurrentExecution.with(execution) do + exploit.handle_exception(err) + end + end + + it 'is a no-op when no execution is active (capture_exception! short-circuits)' do + err = StandardError.new('boom') + expect(::Mdm::ModuleExecutionError).not_to receive(:create!) + + exploit.handle_exception(err) + end + end + + describe 'Msf::Simple::Auxiliary unhandled exception rescue' do + let(:mod) do + framework = double( + 'framework', + events: double('events', on_module_run: nil, on_module_complete: nil) + ) + m = double( + 'aux', + framework: framework, + refname: 'scanner/test', + fullname: 'auxiliary/scanner/test', + type: 'auxiliary', + cleanup: nil, + report_failure: nil, + print_error: nil + ) + allow(m).to receive(:check_code=) + allow(m).to receive(:last_vuln_attempt=) + allow(m).to receive(:respond_to?).and_return(true) + allow(m).to receive(:setup) { raise StandardError, 'kaboom' } + allow(m).to receive(:error=) + allow(m).to receive(:fail_reason=) + allow(m).to receive(:fail_detail=) + allow(m).to receive(:fail_reason).and_return(Msf::Module::Failure::Unknown) + allow(m).to receive(:fail_detail).and_return(nil) + allow(m).to receive(:error).and_return(StandardError.new('kaboom')) + m + end + + it 'persists an Mdm::ModuleExecutionError with phase setup' do + allow(Msf::Reporting::Execution).to receive(:start!).and_return(execution) + allow(Msf::Reporting::Execution).to receive(:finalize!) + + expect(::Mdm::ModuleExecutionError).to receive(:create!).with( + hash_including( + module_execution: execution, + lifecycle_phase: 'setup', + exception_class: 'StandardError' + ) + ) + + listener = Msf::Simple::NoopJobListener.instance + ctx = [mod, 'uuid', listener, { originating_ui: 'console', kind: 'run' }] + Msf::Simple::Auxiliary.send(:job_run_proc, ctx, &:run) + end + end + + describe 'Msf::Simple::Post unhandled exception rescue' do + let(:mod) do + session = double('session') + framework = double( + 'framework', + events: double('events', + on_module_run: nil, + on_session_module_run: nil, + on_module_complete: nil), + sessions: double('sessions', get: session) + ) + m = double( + 'post_mod', + framework: framework, + refname: 'windows/test', + fullname: 'post/windows/test', + type: 'post', + datastore: { 'SESSION' => 1 }, + cleanup: nil, + print_error: nil, + setup: nil + ) + allow(m).to receive(:run) { raise StandardError, 'boom' } + allow(m).to receive(:error=) + allow(m).to receive(:error).and_return(StandardError.new('boom')) + # No respond_to? override: a pure double already reports +true+ for + # stubbed methods like +:type+, +:refname+, +:fullname+, which is + # what +phase_for+ → +module_type_for+ needs to map to 'post'. + m + end + + it 'persists an Mdm::ModuleExecutionError with phase post' do + allow(Msf::Reporting::Execution).to receive(:start!).and_return(execution) + allow(Msf::Reporting::Execution).to receive(:finalize!) + + expect(::Mdm::ModuleExecutionError).to receive(:create!).with( + hash_including( + module_execution: execution, + lifecycle_phase: 'post', + exception_class: 'StandardError' + ) + ) + + ctx = [mod, { originating_ui: 'console' }] + Msf::Simple::Post.send(:job_run_proc, ctx) + end + end +end diff --git a/spec/lib/msf/core/payload_generator_lifecycle_spec.rb b/spec/lib/msf/core/payload_generator_lifecycle_spec.rb new file mode 100644 index 0000000000000..e092efac497f0 --- /dev/null +++ b/spec/lib/msf/core/payload_generator_lifecycle_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Verifies the +wrap_with_execution_lifecycle+ helper so standalone +# encoder/NOP invocations are bracketed in an +Mdm::ModuleExecution+, +# while nested invocations (e.g. from inside another module's +# execution) re-use the existing row instead of double-counting. +RSpec.describe Msf::PayloadGenerator, '#wrap_with_execution_lifecycle' do + let(:framework) { double('framework') } + let(:mod) do + instance_double( + 'Msf::Module', + fullname: 'encoder/x86/shikata_ga_nai', + refname: 'x86/shikata_ga_nai', + type: 'encoder', + datastore: {} + ) + end + let(:execution) { double('execution', id: 17) } + + # Reach the private helper without instantiating the full generator, + # which would require a fully wired payload module. + let(:generator) do + obj = Msf::PayloadGenerator.allocate + obj.instance_variable_set(:@framework, framework) + obj + end + + before { Msf::Reporting::CurrentExecution.clear } + after { Msf::Reporting::CurrentExecution.clear } + + it 'starts an execution, exposes it as current, and finalizes success on a clean run' do + expect(Msf::Reporting::Execution).to receive(:start!).with( + hash_including(framework: framework, mod: mod, originating_ui: 'console', kind: 'run') + ).and_return(execution) + expect(Msf::Reporting::Execution).to receive(:finalize!).with( + execution, + terminal_status: 'success' + ) + + yielded = false + generator.send(:wrap_with_execution_lifecycle, mod) do + yielded = true + expect(Msf::Reporting::CurrentExecution.current).to be(execution) + end + expect(yielded).to be(true) + expect(Msf::Reporting::CurrentExecution.current).to be_nil + end + + it 'finalizes unhandled_exception and re-raises when the block raises' do + allow(Msf::Reporting::Execution).to receive(:start!).and_return(execution) + expect(Msf::Reporting::Execution).to receive(:finalize!).with( + execution, + terminal_status: 'unhandled_exception' + ) + + expect do + generator.send(:wrap_with_execution_lifecycle, mod) { raise 'kaboom' } + end.to raise_error('kaboom') + end + + it 'skips lifecycle calls when already inside another execution (nested guard)' do + outer = double('outer_execution', id: 5) + expect(Msf::Reporting::Execution).not_to receive(:start!) + expect(Msf::Reporting::Execution).not_to receive(:finalize!) + + Msf::Reporting::CurrentExecution.with(outer) do + result = generator.send(:wrap_with_execution_lifecycle, mod) do + expect(Msf::Reporting::CurrentExecution.current).to be(outer) + :nested_value + end + expect(result).to eq(:nested_value) + end + end + + it 'still yields the block when start! returns nil (graceful degradation)' do + allow(Msf::Reporting::Execution).to receive(:start!).and_return(nil) + expect(Msf::Reporting::Execution).not_to receive(:finalize!) + + yielded = false + generator.send(:wrap_with_execution_lifecycle, mod) { yielded = true } + expect(yielded).to be(true) + end +end diff --git a/spec/lib/msf/core/payload_generator_payload_wrap_spec.rb b/spec/lib/msf/core/payload_generator_payload_wrap_spec.rb new file mode 100644 index 0000000000000..71d8bc9e3b637 --- /dev/null +++ b/spec/lib/msf/core/payload_generator_payload_wrap_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Standalone +msfvenom+ payload generation (i.e. +# +PayloadGenerator#generate_raw_payload+) brackets the +# +payload_module.generate_simple+ call in +# +wrap_with_execution_lifecycle+ so that +fail_with+ raised from +# inside the payload's +generate+ method records a row against the +# transient execution. +RSpec.describe Msf::PayloadGenerator, '#generate_raw_payload execution wrap' do + let(:framework) { double('framework') } + let(:payload_module) do + double( + 'payload_module', + fullname: 'payload/test/raw', + refname: 'test/raw', + type: 'payload', + datastore: {} + ) + end + + let(:generator) do + obj = Msf::PayloadGenerator.allocate + obj.instance_variable_set(:@framework, framework) + obj.instance_variable_set(:@payload, 'test/raw') + obj.instance_variable_set(:@datastore, {}) + obj.instance_variable_set(:@space, 1024) + allow(obj).to receive(:payload_module).and_return(payload_module) + allow(obj).to receive(:choose_platform).and_return(double('platform', platforms: [:linux])) + allow(obj).to receive(:choose_arch).and_return(:x86) + obj + end + + before { Msf::Reporting::CurrentExecution.clear } + after { Msf::Reporting::CurrentExecution.clear } + + it 'wraps payload_module.generate_simple in wrap_with_execution_lifecycle' do + expect(generator).to receive(:wrap_with_execution_lifecycle).with(payload_module).and_yield + expect(payload_module).to receive(:generate_simple).and_return('shellcode') + + expect(generator.generate_raw_payload).to eq('shellcode') + end +end diff --git a/spec/lib/msf/core/reporting/backends/db_manager_backend_spec.rb b/spec/lib/msf/core/reporting/backends/db_manager_backend_spec.rb new file mode 100644 index 0000000000000..37ecd443f684d --- /dev/null +++ b/spec/lib/msf/core/reporting/backends/db_manager_backend_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'support/reporting/shared_examples' + +RSpec.describe Msf::Reporting::Backends::DbManagerBackend do + let(:fake_db) do + db = double('Msf::DBManager') + allow(db).to receive(:active).and_return(true) + db + end + let(:framework) { double('Msf::Framework', db: fake_db) } + + subject(:backend) { described_class.new(framework: framework) } + + let(:fake_host) { double('Mdm::Host', id: 42) } + + before do + # Bypass the ActiveRecord connection-pool checkout so specs run without + # a live PostgreSQL connection. The end-to-end pool path is exercised by + # the +ConnectionPool+ spec. + allow(Msf::Reporting::ConnectionPool).to receive(:with_connection) { |&block| block.call } + + allow(fake_db).to receive(:report_host).and_return(fake_host) + end + + def simulate_db_inactive! + allow(fake_db).to receive(:active).and_return(false) + end + + def inject_failure(method, error) + # The backend wraps any +StandardError+ raised by +framework.db.+ + # into a fresh +BackendError+ with the same message; raise the same + # class/message the shared example queued so the assertion holds. + allow(fake_db).to receive(method).and_raise(error.class, error.message) + end + + it_behaves_like 'a reporting backend report_host' + + describe '#report_host' do + it 'translates address: into the legacy :host opts key when delegating to framework.db' do + expect(fake_db).to receive(:report_host).with(hash_including(host: '192.0.2.10', os_name: 'Linux')).and_return(fake_host) + + backend.report_host(address: '192.0.2.10', os_name: 'Linux') + end + + it 'returns Skipped(:db_inactive) when framework.db.report_host returns nil' do + allow(fake_db).to receive(:report_host).and_return(nil) + + result = backend.report_host(address: '192.0.2.10') + expect(result).to be_a(Msf::Reporting::Results::Skipped) + expect(result.reason).to eq(:db_inactive) + end + + it 'returns Skipped(:db_inactive) without delegating when framework.db is inactive' do + allow(fake_db).to receive(:active).and_return(false) + expect(fake_db).not_to receive(:report_host) + + result = backend.report_host(address: '192.0.2.10') + expect(result).to be_a(Msf::Reporting::Results::Skipped) + expect(result.reason).to eq(:db_inactive) + end + + it 'wraps a non-BackendError exception from framework.db.report_host into Failed(BackendError)' do + allow(fake_db).to receive(:report_host).and_raise(ArgumentError, 'bad addr') + + result = backend.report_host(address: 'not-an-ip') + expect(result).to be_a(Msf::Reporting::Results::Failed) + expect(result.error).to be_a(Msf::Reporting::BackendError) + expect(result.error.message).to eq('bad addr') + end + + it 'preserves an already-typed BackendError raised by framework.db.report_host' do + typed = Msf::Reporting::BackendError.new('typed') + allow(fake_db).to receive(:report_host).and_raise(typed) + + result = backend.report_host(address: '192.0.2.10') + expect(result.error).to be(typed) + end + + it 'checks out an ActiveRecord connection via Msf::Reporting::ConnectionPool.with_connection' do + expect(Msf::Reporting::ConnectionPool).to receive(:with_connection).and_call_original + + backend.report_host(address: '192.0.2.10') + end + + it 'populates Persisted#row_id from the returned Mdm::Host id' do + result = backend.report_host(address: '192.0.2.10') + expect(result.row_id).to eq(42) + expect(result.row).to be(fake_host) + expect(result.touched).to be(false) + end + end +end diff --git a/spec/lib/msf/core/reporting/backends/in_memory_backend_spec.rb b/spec/lib/msf/core/reporting/backends/in_memory_backend_spec.rb new file mode 100644 index 0000000000000..e262beb062252 --- /dev/null +++ b/spec/lib/msf/core/reporting/backends/in_memory_backend_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'support/reporting/shared_examples' + +RSpec.describe Msf::Reporting::Backends::InMemoryBackend do + subject(:backend) { described_class.new } + + def simulate_db_inactive! + backend.db_inactive! + end + + def inject_failure(method, error) + backend.fail_next!(method, error) + end + + it_behaves_like 'a reporting backend' + + describe '#calls' do + it 'records every call in declaration order with method, entity_type, and frozen kwargs' do + backend.report_host(address: '192.0.2.10') + backend.report_service(host: 1, port: 80, proto: 'tcp') + + expect(backend.calls.length).to eq(2) + expect(backend.calls[0][:method]).to eq(:report_host) + expect(backend.calls[0][:entity_type]).to eq(:host) + expect(backend.calls[0][:kwargs]).to eq(address: '192.0.2.10') + expect(backend.calls[0][:kwargs]).to be_frozen + expect(backend.calls[1][:method]).to eq(:report_service) + end + + it 'returns ascending row_ids for successive Persisted results' do + r1 = backend.report_host(address: '192.0.2.10') + r2 = backend.report_host(address: '192.0.2.11') + + expect(r2.row_id).to be > r1.row_id + end + end + + describe '#fail_next!' do + it 'consumes one queued failure per matching call' do + backend.fail_next!(:report_host, Msf::Reporting::BackendError.new('1')) + + expect(backend.report_host(address: '192.0.2.10')).to be_a(Msf::Reporting::Results::Failed) + expect(backend.report_host(address: '192.0.2.11')).to be_a(Msf::Reporting::Results::Persisted) + end + end + + describe '#reset!' do + it 'clears recorded calls and failure injections' do + backend.report_host(address: '192.0.2.10') + backend.fail_next!(:report_host, Msf::Reporting::BackendError.new('x')) + + backend.reset! + + expect(backend.calls).to be_empty + expect(backend.report_host(address: '192.0.2.11')).to be_a(Msf::Reporting::Results::Persisted) + end + end + + describe '#report_session' do + let(:session) { Object.new } + + it 'records both the compound entry and per-step single-entity calls' do + backend.report_session( + host: { address: '192.0.2.10' }, + service: { port: 445, proto: 'tcp' }, + session: session + ) + + methods = backend.calls.map { |c| c[:method] } + expect(methods).to include(:report_session, :report_host, :report_service, :persist_session) + end + + it 'raises Msf::Reporting::CompoundError on :failed when raise_on_failure (default) is true' do + backend.fail_next!(:report_host, Msf::Reporting::BackendError.new('host failure')) + + expect do + backend.report_session( + host: { address: '192.0.2.10' }, + service: { port: 445, proto: 'tcp' }, + session: session + ) + end.to raise_error(Msf::Reporting::CompoundError) + end + + it 'returns the Compound result without raising when raise_on_failure: false' do + backend.fail_next!(:report_host, Msf::Reporting::BackendError.new('host failure')) + + result = backend.report_session( + host: { address: '192.0.2.10' }, + service: { port: 445, proto: 'tcp' }, + session: session, + raise_on_failure: false + ) + + expect(result).to be_a(Msf::Reporting::Results::Compound) + expect(%i[partial failed]).to include(result.overall) + end + + it 'omits optional vuln/exploit_attempt steps when not given' do + result = backend.report_session( + host: { address: '192.0.2.10' }, + service: { port: 445, proto: 'tcp' }, + session: session + ) + + expect(result.steps.map(&:entity_type)).not_to include(:vuln, :exploit_attempt) + end + + it 'includes optional vuln/exploit_attempt steps when given' do + result = backend.report_session( + host: { address: '192.0.2.10' }, + service: { port: 445, proto: 'tcp' }, + session: session, + vuln: { name: 'CVE-2024-XXXX' }, + exploit_attempt: { module: 'exploit/multi/foo', attempted_at: Time.now } + ) + + expect(result.steps.map(&:entity_type)).to include(:vuln, :exploit_attempt) + end + end + + describe 'method coverage' do + it 'implements every method named in ENTITY_TYPE_BY_METHOD' do + described_class::ENTITY_TYPE_BY_METHOD.each_key do |method| + expect(backend).to respond_to(method) + end + end + end +end diff --git a/spec/lib/msf/core/reporting/connection_pool_spec.rb b/spec/lib/msf/core/reporting/connection_pool_spec.rb new file mode 100644 index 0000000000000..34a66bd0bcbd9 --- /dev/null +++ b/spec/lib/msf/core/reporting/connection_pool_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Msf::Reporting::ConnectionPool do + describe '.with_connection' do + let(:pool) do + pool_double = double('connection_pool') + allow(pool_double).to receive(:with_connection) { |&block| block.call } + pool_double + end + + before do + allow(::ApplicationRecord).to receive(:connection_pool).and_return(pool) + end + + it 'raises when called without a block' do + expect { described_class.with_connection }.to raise_error(LocalJumpError) + end + + it 'yields and returns the block value' do + expect(described_class.with_connection { 42 }).to eq(42) + end + + it 'checks out an AR connection via ApplicationRecord.connection_pool' do + expect(pool).to receive(:with_connection) { |&block| block.call } + described_class.with_connection { :ok } + end + + it 'propagates exceptions raised inside the block' do + expect do + described_class.with_connection { raise ArgumentError, 'boom' } + end.to raise_error(ArgumentError, 'boom') + end + + context 'release semantics (delegated to ActiveRecord)' do + let(:released) { [] } + + before do + allow(pool).to receive(:with_connection) do |&block| + block.call + ensure + released << :released + end + end + + it 'releases the connection on normal block exit' do + described_class.with_connection { :ok } + expect(released).to eq([:released]) + end + + it 'releases the connection when the block raises' do + expect do + described_class.with_connection { raise 'x' } + end.to raise_error('x') + + expect(released).to eq([:released]) + end + + it 'releases the connection on early return inside the block' do + helper = Class.new do + def call + Msf::Reporting::ConnectionPool.with_connection do + return :early + end + :did_not_return + end + end + + expect(helper.new.call).to eq(:early) + expect(released).to eq([:released]) + end + + it 'releases the connection when Thread#raise interrupts the block' do + ready = Queue.new + + thread = Thread.new do + Thread.current.report_on_exception = false + described_class.with_connection do + ready << :inside + sleep + end + end + + ready.pop + thread.raise(StandardError, 'interrupted') + + expect { thread.value }.to raise_error(StandardError, 'interrupted') + expect(released).to eq([:released]) + end + end + + context 'when ApplicationRecord is not defined' do + before { hide_const('ApplicationRecord') } + + it 'yields the block directly without any AR interaction' do + expect(described_class.with_connection { 7 }).to eq(7) + end + end + end +end diff --git a/spec/lib/msf/core/reporting/current_execution_spec.rb b/spec/lib/msf/core/reporting/current_execution_spec.rb new file mode 100644 index 0000000000000..2a3fdcd192f9f --- /dev/null +++ b/spec/lib/msf/core/reporting/current_execution_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Msf::Reporting::CurrentExecution do + after { described_class.clear } + + describe '.current' do + it 'is nil when no execution has been set' do + expect(described_class.current).to be_nil + end + end + + describe '.id' do + it 'returns nil when no execution has been set' do + expect(described_class.id).to be_nil + end + + it 'returns the id of the current execution' do + execution = double('execution', id: 42) + described_class.with(execution) do + expect(described_class.id).to eq(42) + end + end + + it 'returns nil when the current execution does not respond to #id' do + described_class.with(Object.new) do + expect(described_class.id).to be_nil + end + end + end + + describe '.with' do + it 'raises LocalJumpError when called without a block' do + expect { described_class.with(double('execution')) }.to raise_error(LocalJumpError) + end + + it 'sets the current execution for the duration of the block' do + execution = double('execution') + described_class.with(execution) do + expect(described_class.current).to be(execution) + end + expect(described_class.current).to be_nil + end + + it 'returns the block value' do + expect(described_class.with(double('execution')) { :ok }).to eq(:ok) + end + + it 'restores the previous value when nested' do + outer = double('outer') + inner = double('inner') + described_class.with(outer) do + expect(described_class.current).to be(outer) + described_class.with(inner) do + expect(described_class.current).to be(inner) + end + expect(described_class.current).to be(outer) + end + end + + it 'restores the previous value when the block raises' do + execution = double('execution') + expect do + described_class.with(execution) { raise 'boom' } + end.to raise_error('boom') + expect(described_class.current).to be_nil + end + + it 'accepts a nil execution and clears the slot inside the block' do + outer = double('outer') + described_class.with(outer) do + described_class.with(nil) do + expect(described_class.current).to be_nil + end + expect(described_class.current).to be(outer) + end + end + end + + describe '.clear' do + it 'clears the current execution' do + execution = double('execution') + Thread.current[:msf_reporting_current_execution] = execution + described_class.clear + expect(described_class.current).to be_nil + end + end + + describe 'thread isolation' do + it 'keeps the current execution per-thread' do + execution = double('execution') + other_thread_value = nil + described_class.with(execution) do + Thread.new { other_thread_value = described_class.current }.join + end + expect(other_thread_value).to be_nil + end + end +end diff --git a/spec/lib/msf/core/reporting/errors_spec.rb b/spec/lib/msf/core/reporting/errors_spec.rb new file mode 100644 index 0000000000000..e858019f44e9d --- /dev/null +++ b/spec/lib/msf/core/reporting/errors_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Msf::Reporting error hierarchy' do + it 'roots every reporting error at Msf::Reporting::Error' do + [ + Msf::Reporting::ValidationError, + Msf::Reporting::CompoundError, + Msf::Reporting::DetachedSessionError, + Msf::Reporting::BackendError, + Msf::Reporting::DbInactiveError, + Msf::Reporting::RemoteServiceError + ].each do |klass| + expect(klass.ancestors).to include(Msf::Reporting::Error) + end + end + + it 'roots Msf::Reporting::Error at StandardError so generic rescues still catch it' do + expect(Msf::Reporting::Error.ancestors).to include(StandardError) + end + + it 'nests DbInactiveError and RemoteServiceError under BackendError' do + expect(Msf::Reporting::DbInactiveError.ancestors).to include(Msf::Reporting::BackendError) + expect(Msf::Reporting::RemoteServiceError.ancestors).to include(Msf::Reporting::BackendError) + end + + describe Msf::Reporting::ValidationError do + it 'carries field/reason metadata and produces a sensible default message' do + err = described_class.new(field: :address, reason: :missing) + expect(err.field).to eq(:address) + expect(err.reason).to eq(:missing) + expect(err.message).to match(/missing required kwarg :address/) + end + + it 'formats type errors with expected vs got' do + err = described_class.new(field: :port, reason: :type, expected: Integer, got: 'oops') + expect(err.message).to match(/expected Integer, got String/) + end + + it 'formats enum errors with allowed and got values' do + err = described_class.new(field: :proto, reason: :enum, allowed: %w[tcp udp], got: 'tcq') + expect(err.allowed).to eq(%w[tcp udp]) + expect(err.message).to match(/not in \["tcp", "udp"\].*got "tcq"/) + end + + it 'formats conflict errors with the offending field list' do + err = described_class.new(reason: :conflict, fields: %i[host address]) + expect(err.fields).to eq(%i[host address]) + expect(err.message).to match(/conflicting kwargs/) + end + + it 'accepts an explicit message override' do + err = described_class.new('custom', field: :foo, reason: :missing) + expect(err.message).to eq('custom') + end + end + + describe Msf::Reporting::CompoundError do + it 'carries the underlying compound result' do + result = Msf::Reporting::Results::Compound.new(steps: [], overall: :failed) + err = described_class.new(result) + + expect(err.result).to be(result) + expect(err.message).to match(/overall=:failed/) + end + end +end diff --git a/spec/lib/msf/core/reporting/execution_ivar_helpers_spec.rb b/spec/lib/msf/core/reporting/execution_ivar_helpers_spec.rb new file mode 100644 index 0000000000000..6e3422fd105fd --- /dev/null +++ b/spec/lib/msf/core/reporting/execution_ivar_helpers_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Direct unit coverage for the +@api private+ ivar helpers added by +# the reporting refactor. Two pairs/trios are exercised here: +# +# * +mark_exception_recorded+ / +exception_recorded?+ — dedup flag +# on exception objects so the same exception is not persisted +# twice when it bubbles through multiple rescue layers. +# * +mark_module_unhandled_exception+ / +# +clear_module_unhandled_exception+ / +# +module_unhandled_exception?+ — flag on the module instance +# consulted by the simple wrappers to decide between +# +expected_failure+ and +unhandled_exception+ terminal statuses. +RSpec.describe Msf::Reporting::Execution, 'ivar helpers' do + describe '.mark_exception_recorded / .exception_recorded?' do + let(:exception) { StandardError.new('boom') } + + it 'returns false for a fresh exception' do + expect(described_class.exception_recorded?(exception)).to be(false) + end + + it 'returns true after the exception has been marked' do + described_class.mark_exception_recorded(exception) + expect(described_class.exception_recorded?(exception)).to be(true) + end + + it 'sets the documented ivar' do + described_class.mark_exception_recorded(exception) + expect( + exception.instance_variable_get(described_class::RECORDED_EXCEPTION_IVAR) + ).to be(true) + end + + it 'is idempotent across repeated marks' do + described_class.mark_exception_recorded(exception) + described_class.mark_exception_recorded(exception) + expect(described_class.exception_recorded?(exception)).to be(true) + end + + it 'does not leak between distinct exception objects' do + other = RuntimeError.new('other') + described_class.mark_exception_recorded(exception) + expect(described_class.exception_recorded?(other)).to be(false) + end + + it 'returns nil and swallows when instance_variable_set raises' do + frozen = StandardError.new('frozen').freeze + expect { described_class.mark_exception_recorded(frozen) }.not_to raise_error + expect(described_class.exception_recorded?(frozen)).to be(false) + end + + it 'returns false when the predicate itself raises' do + bogus = Object.new + allow(bogus).to receive(:instance_variable_defined?).and_raise(StandardError) + expect(described_class.exception_recorded?(bogus)).to be(false) + end + end + + describe '.mark_module_unhandled_exception / .module_unhandled_exception? / .clear_module_unhandled_exception' do + let(:mod) { Object.new } + + it 'returns false for a fresh module' do + expect(described_class.module_unhandled_exception?(mod)).to be(false) + end + + it 'returns true after the module has been marked' do + described_class.mark_module_unhandled_exception(mod) + expect(described_class.module_unhandled_exception?(mod)).to be(true) + end + + it 'sets the documented ivar on the module instance' do + described_class.mark_module_unhandled_exception(mod) + expect( + mod.instance_variable_get(described_class::UNHANDLED_EXCEPTION_IVAR) + ).to be(true) + end + + it 'returns false again after the flag is cleared' do + described_class.mark_module_unhandled_exception(mod) + described_class.clear_module_unhandled_exception(mod) + expect(described_class.module_unhandled_exception?(mod)).to be(false) + end + + it 'removes the ivar on clear so the module is indistinguishable from a fresh instance' do + described_class.mark_module_unhandled_exception(mod) + described_class.clear_module_unhandled_exception(mod) + expect( + mod.instance_variable_defined?(described_class::UNHANDLED_EXCEPTION_IVAR) + ).to be(false) + end + + it 'clear is a no-op when the flag was never set' do + expect { described_class.clear_module_unhandled_exception(mod) }.not_to raise_error + expect(described_class.module_unhandled_exception?(mod)).to be(false) + end + + it 'clear handles nil modules without raising' do + expect { described_class.clear_module_unhandled_exception(nil) }.not_to raise_error + end + + it 'predicate returns false for a nil module' do + expect(described_class.module_unhandled_exception?(nil)).to be(false) + end + + it 'does not leak between distinct module instances' do + other = Object.new + described_class.mark_module_unhandled_exception(mod) + expect(described_class.module_unhandled_exception?(other)).to be(false) + end + + it 'mark swallows errors raised by instance_variable_set' do + frozen = Object.new.freeze + expect { described_class.mark_module_unhandled_exception(frozen) }.not_to raise_error + expect(described_class.module_unhandled_exception?(frozen)).to be(false) + end + + it 'clear swallows errors raised by remove_instance_variable' do + bogus = Object.new + allow(bogus).to receive(:instance_variable_defined?).and_return(true) + allow(bogus).to receive(:remove_instance_variable).and_raise(StandardError) + expect { described_class.clear_module_unhandled_exception(bogus) }.not_to raise_error + end + + it 'predicate returns false when introspection raises' do + bogus = Object.new + allow(bogus).to receive(:instance_variable_defined?).and_raise(StandardError) + expect(described_class.module_unhandled_exception?(bogus)).to be(false) + end + + it 'a marked-then-cleared module can be re-marked' do + described_class.mark_module_unhandled_exception(mod) + described_class.clear_module_unhandled_exception(mod) + described_class.mark_module_unhandled_exception(mod) + expect(described_class.module_unhandled_exception?(mod)).to be(true) + end + end +end diff --git a/spec/lib/msf/core/reporting/execution_phase_spec.rb b/spec/lib/msf/core/reporting/execution_phase_spec.rb new file mode 100644 index 0000000000000..e31d1c1c792c1 --- /dev/null +++ b/spec/lib/msf/core/reporting/execution_phase_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Phase resolution helpers. Resolution order: +# 1. execution.kind == 'check' -> PHASE_CHECK +# 2. module-type fallback (exploit/post/run) +# +with_phase_setup+ / +with_phase_cleanup+ record errors raised +# inside their block against the active execution with the matching +# explicit lifecycle_phase. +RSpec.describe Msf::Reporting::Execution, '.phase_for / .with_phase_setup / .with_phase_cleanup' do + let(:execution) { double('Mdm::ModuleExecution', id: 1, kind: 'run') } + let(:check_execution) { double('Mdm::ModuleExecution', id: 2, kind: 'check') } + let(:exploit_mod) { double('exploit_mod', type: 'exploit') } + let(:aux_mod) { double('aux_mod', type: 'auxiliary') } + let(:post_mod) { double('post_mod', type: 'post') } + + describe '.phase_for' do + it 'returns PHASE_CHECK when the execution kind is check' do + expect(described_class.phase_for(exploit_mod, execution: check_execution)).to eq('check') + end + + it 'returns PHASE_EXPLOIT for an exploit module' do + expect(described_class.phase_for(exploit_mod, execution: execution)).to eq('exploit') + end + + it 'returns PHASE_POST for a post module' do + expect(described_class.phase_for(post_mod, execution: execution)).to eq('post') + end + + it 'returns PHASE_RUN for any other module type' do + expect(described_class.phase_for(aux_mod, execution: execution)).to eq('run') + end + + it 'returns the module-type fallback when execution is nil' do + expect(described_class.phase_for(exploit_mod, execution: nil)).to eq('exploit') + end + end + + describe '.with_phase_setup' do + it 'returns the block value on the happy path with no DB write' do + expect(::Mdm::ModuleExecutionError).not_to receive(:create!) + + Msf::Reporting::CurrentExecution.with(execution) do + result = described_class.with_phase_setup(exploit_mod) { :ok } + expect(result).to eq(:ok) + end + end + + it 'records the exception with lifecycle_phase setup and re-raises' do + err = StandardError.new('boom') + + expect(::Mdm::ModuleExecutionError).to receive(:create!).with( + hash_including( + module_execution: execution, + lifecycle_phase: 'setup', + exception_class: 'StandardError' + ) + ) + + expect do + Msf::Reporting::CurrentExecution.with(execution) do + described_class.with_phase_setup(exploit_mod) { raise err } + end + end.to raise_error(StandardError, 'boom') + end + + it 'is a no-op when no execution is active' do + expect(::Mdm::ModuleExecutionError).not_to receive(:create!) + + expect do + described_class.with_phase_setup(exploit_mod) { raise StandardError, 'no exec' } + end.to raise_error(StandardError) + end + end + + describe '.with_phase_cleanup' do + it 'returns the block value on the happy path with no DB write' do + expect(::Mdm::ModuleExecutionError).not_to receive(:create!) + + Msf::Reporting::CurrentExecution.with(execution) do + result = described_class.with_phase_cleanup(exploit_mod) { :ok } + expect(result).to eq(:ok) + end + end + + it 'records the exception with lifecycle_phase cleanup and re-raises' do + err = StandardError.new('cleanup blew up') + + expect(::Mdm::ModuleExecutionError).to receive(:create!).with( + hash_including( + module_execution: execution, + lifecycle_phase: 'cleanup', + exception_class: 'StandardError' + ) + ) + + expect do + Msf::Reporting::CurrentExecution.with(execution) do + described_class.with_phase_cleanup(exploit_mod) { raise err } + end + end.to raise_error(StandardError, 'cleanup blew up') + end + + it 'overrides the module-type fallback even when the execution kind is run' do + err = StandardError.new('cleanup blew up') + + expect(::Mdm::ModuleExecutionError).to receive(:create!).with( + hash_including(lifecycle_phase: 'cleanup') + ) + + expect do + Msf::Reporting::CurrentExecution.with(execution) do + described_class.with_phase_cleanup(exploit_mod) { raise err } + end + end.to raise_error(StandardError) + end + + it 'is a no-op when no execution is active' do + expect(::Mdm::ModuleExecutionError).not_to receive(:create!) + + expect do + described_class.with_phase_cleanup(exploit_mod) { raise StandardError, 'no exec' } + end.to raise_error(StandardError) + end + + it 'deduplicates against an already-recorded exception ivar' do + err = StandardError.new('already handled') + Msf::Reporting::Execution.mark_exception_recorded(err) + + expect(::Mdm::ModuleExecutionError).not_to receive(:create!) + + expect do + Msf::Reporting::CurrentExecution.with(execution) do + described_class.with_phase_cleanup(exploit_mod) { raise err } + end + end.to raise_error(StandardError) + end + end +end diff --git a/spec/lib/msf/core/reporting/execution_record_error_spec.rb b/spec/lib/msf/core/reporting/execution_record_error_spec.rb new file mode 100644 index 0000000000000..59f18e8c57390 --- /dev/null +++ b/spec/lib/msf/core/reporting/execution_record_error_spec.rb @@ -0,0 +1,215 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Msf::Reporting::Execution do + let(:execution) { double('Mdm::ModuleExecution', id: 42, kind: 'run') } + + describe '.record_error!' do + it 'returns nil and does not touch the schema when execution is nil' do + expect(::Mdm::ModuleExecutionError).not_to receive(:create!) + expect( + described_class.record_error!(nil, lifecycle_phase: 'run') + ).to be_nil + end + + it 'persists an exception with class, message, backtrace, and phase' do + ex = StandardError.new('boom') + ex.set_backtrace(['line one', 'line two']) + now = Time.utc(2026, 6, 1, 12, 0, 0) + + expect(::Mdm::ModuleExecutionError).to receive(:create!).with( + hash_including( + module_execution: execution, + lifecycle_phase: 'exploit', + exception_class: 'StandardError', + message: 'boom', + backtrace: "line one\nline two", + failure_reason: nil, + occurred_at: now + ) + ) + + described_class.record_error!( + execution, + lifecycle_phase: 'exploit', + exception: ex, + occurred_at: now + ) + end + + it 'persists a fail_with-driven failure with exception_class nil' do + expect(::Mdm::ModuleExecutionError).to receive(:create!).with( + hash_including( + module_execution: execution, + lifecycle_phase: 'run', + exception_class: nil, + message: 'Unable to login', + backtrace: nil, + failure_reason: 'no-access' + ) + ) + + described_class.record_error!( + execution, + lifecycle_phase: 'run', + failure_reason: 'no-access', + message: 'Unable to login' + ) + end + + it 'lets an explicit message override the exception message' do + ex = RuntimeError.new('raw') + expect(::Mdm::ModuleExecutionError).to receive(:create!).with( + hash_including(message: 'curated', exception_class: 'RuntimeError') + ) + + described_class.record_error!( + execution, + lifecycle_phase: 'run', + exception: ex, + message: 'curated' + ) + end + + it 'returns nil and warns when create! raises' do + ex = StandardError.new('x') + allow(::Mdm::ModuleExecutionError).to receive(:create!).and_raise(StandardError, 'db down') + expect(described_class).to receive(:wlog).with(/failed to record ModuleExecutionError for execution #42/) + expect( + described_class.record_error!(execution, lifecycle_phase: 'run', exception: ex) + ).to be_nil + end + end + + describe '.truncate_backtrace_for_storage' do + it 'returns nil for nil input' do + expect(described_class.truncate_backtrace_for_storage(nil)).to be_nil + end + + it 'returns nil for an empty array' do + expect(described_class.truncate_backtrace_for_storage([])).to be_nil + end + + it 'joins an array backtrace with newlines verbatim when within the cap' do + expect( + described_class.truncate_backtrace_for_storage(['a', 'b', 'c']) + ).to eq("a\nb\nc") + end + + it 'truncates to max_bytes when above the cap' do + huge = ['x' * 200_000] + result = described_class.truncate_backtrace_for_storage(huge, max_bytes: 1024) + expect(result.bytesize).to eq(1024) + expect(result).to eq('x' * 1024) + end + + it 'caps at MAX_BACKTRACE_BYTES by default' do + huge = ['x' * (described_class::MAX_BACKTRACE_BYTES + 10)] + result = described_class.truncate_backtrace_for_storage(huge) + expect(result.bytesize).to eq(described_class::MAX_BACKTRACE_BYTES) + end + end + + describe '.phase_for' do + it 'returns check when the execution is a check, regardless of module type' do + mod = double('mod', type: 'exploit') + check_execution = double('execution', kind: 'check') + expect(described_class.phase_for(mod, execution: check_execution)).to eq('check') + end + + it 'maps exploits to exploit phase' do + mod = double('mod', type: 'exploit') + run_execution = double('execution', kind: 'run') + expect(described_class.phase_for(mod, execution: run_execution)).to eq('exploit') + end + + it 'maps post modules to post phase' do + mod = double('mod', type: 'post') + run_execution = double('execution', kind: 'run') + expect(described_class.phase_for(mod, execution: run_execution)).to eq('post') + end + + it 'maps everything else to run phase' do + run_execution = double('execution', kind: 'run') + %w[auxiliary evasion encoder nop payload].each do |module_type| + mod = double('mod', type: module_type) + expect(described_class.phase_for(mod, execution: run_execution)).to eq('run') + end + end + end + + describe '.capture_exception!' do + let(:mod) { double('mod', type: 'auxiliary') } + let(:exception) { StandardError.new('boom') } + + it 'returns nil and skips persistence when no current execution is set' do + Msf::Reporting::CurrentExecution.clear + expect(::Mdm::ModuleExecutionError).not_to receive(:create!) + expect(described_class.capture_exception!(mod, exception)).to be_nil + end + + it 'records the exception under the derived phase and marks it as recorded' do + expect(::Mdm::ModuleExecutionError).to receive(:create!).with( + hash_including( + module_execution: execution, + lifecycle_phase: 'run', + exception_class: 'StandardError' + ) + ) + + Msf::Reporting::CurrentExecution.with(execution) do + described_class.capture_exception!(mod, exception) + end + expect(described_class.exception_recorded?(exception)).to be(true) + end + + it 'is idempotent: the same exception is only persisted once' do + expect(::Mdm::ModuleExecutionError).to receive(:create!).once + Msf::Reporting::CurrentExecution.with(execution) do + described_class.capture_exception!(mod, exception) + described_class.capture_exception!(mod, exception) + end + end + + it 'respects an explicit lifecycle_phase override' do + expect(::Mdm::ModuleExecutionError).to receive(:create!).with( + hash_including(lifecycle_phase: 'cleanup') + ) + Msf::Reporting::CurrentExecution.with(execution) do + described_class.capture_exception!(mod, exception, lifecycle_phase: 'cleanup') + end + end + end + + describe '.record_failure!' do + let(:mod) { double('mod', type: 'auxiliary') } + + it 'returns nil when no current execution is set' do + Msf::Reporting::CurrentExecution.clear + expect( + described_class.record_failure!(mod, failure_reason: 'no-access', message: 'no') + ).to be_nil + end + + it 'persists with exception_class nil and the supplied failure_reason / message' do + Msf::Reporting::CurrentExecution.with(execution) do + expect(::Mdm::ModuleExecutionError).to receive(:create!).with( + hash_including( + module_execution: execution, + lifecycle_phase: 'run', + exception_class: nil, + failure_reason: 'no-access', + message: 'Unable' + ) + ) + + described_class.record_failure!( + mod, + failure_reason: 'no-access', + message: 'Unable' + ) + end + end + end +end diff --git a/spec/lib/msf/core/reporting/execution_spec.rb b/spec/lib/msf/core/reporting/execution_spec.rb new file mode 100644 index 0000000000000..99e3cd9d8cc5b --- /dev/null +++ b/spec/lib/msf/core/reporting/execution_spec.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Msf::Reporting::Execution do + let(:datastore) { { 'RHOSTS' => '192.0.2.10', 'RPORT' => 80 } } + let(:mod) do + instance_double( + 'Msf::Module', + fullname: 'exploit/windows/smb/ms17_010_eternalblue', + refname: 'windows/smb/ms17_010_eternalblue', + type: 'exploit', + datastore: datastore + ) + end + let(:workspace) { double('workspace', id: 7) } + let(:db) { double('db', active: true, workspace: workspace) } + let(:framework) { double('framework', db: db) } + + describe '.start!' do + let(:execution_row) { double('execution_row', id: 99) } + + it 'creates a row in the running state with captured attributes' do + expect(::Mdm::ModuleExecution).to receive(:create!).with( + hash_including( + workspace: workspace, + module_reference_name: 'exploit/windows/smb/ms17_010_eternalblue', + module_type: 'exploit', + kind: 'run', + options_snapshot: datastore, + originating_ui: 'console', + parent_execution_id: nil, + terminal_status: 'running', + single_entity_failure_count: 0 + ) + ).and_return(execution_row) + + expect( + described_class.start!(framework: framework, mod: mod, originating_ui: 'console') + ).to be(execution_row) + end + + it 'passes the supplied kind, parent, and started_at through' do + now = Time.utc(2025, 1, 2, 3, 4, 5) + expect(::Mdm::ModuleExecution).to receive(:create!).with( + hash_including( + kind: 'check', + parent_execution_id: 42, + started_at: now + ) + ).and_return(execution_row) + + described_class.start!( + framework: framework, + mod: mod, + originating_ui: :rpc, + parent_execution_id: 42, + kind: 'check', + started_at: now + ) + end + + it 'returns nil and warns when create! raises' do + allow(::Mdm::ModuleExecution).to receive(:create!).and_raise(StandardError, 'db down') + expect(described_class).to receive(:wlog).with(/failed to create ModuleExecution/) + expect( + described_class.start!(framework: framework, mod: mod, originating_ui: 'console') + ).to be_nil + end + + it 'returns nil when no workspace can be resolved' do + empty_db = double('db', active: true, workspace: nil) + empty_framework = double('framework', db: empty_db) + expect(::Mdm::ModuleExecution).not_to receive(:create!) + expect( + described_class.start!(framework: empty_framework, mod: mod, originating_ui: 'console') + ).to be_nil + end + + it 'returns nil when the framework db is inactive' do + inactive_db = double('db', active: false) + inactive_framework = double('framework', db: inactive_db) + expect(::Mdm::ModuleExecution).not_to receive(:create!) + expect( + described_class.start!(framework: inactive_framework, mod: mod, originating_ui: 'console') + ).to be_nil + end + end + + describe '.finalize!' do + it 'is a no-op when execution is nil' do + expect(described_class.finalize!(nil, terminal_status: 'success')).to be_nil + end + + it 'updates the row with ended_at and status' do + execution = double('execution_row', id: 12) + ended = Time.utc(2025, 6, 1, 0, 0, 0) + expect(execution).to receive(:update!).with( + ended_at: ended, + terminal_status: 'expected_failure', + failure_reason: 'no-target', + failure_message: 'no target reached' + ) + described_class.finalize!( + execution, + terminal_status: 'expected_failure', + failure_reason: 'no-target', + failure_message: 'no target reached', + ended_at: ended + ) + end + + it 'rescues update errors and warns' do + execution = double('execution_row', id: 5) + allow(execution).to receive(:update!).and_raise(StandardError, 'boom') + expect(described_class).to receive(:wlog).with(/failed to finalize ModuleExecution #5/) + expect(described_class.finalize!(execution, terminal_status: 'success')).to be(execution) + end + end + + describe '.capture_options_snapshot' do + it 'returns the datastore as a hash' do + expect(described_class.capture_options_snapshot(mod)).to eq(datastore) + end + + it 'returns nil when the module has no datastore' do + bare = double('mod', datastore: nil) + expect(described_class.capture_options_snapshot(bare)).to be_nil + end + + it 'returns nil when the datastore raises' do + bad = double('mod') + allow(bad).to receive(:datastore).and_raise(StandardError) + expect(described_class.capture_options_snapshot(bad)).to be_nil + end + end + + describe '.terminal_status_for_check_code' do + { + 'vulnerable' => 'success', + 'appears' => 'success', + 'safe' => 'neutral', + 'detected' => 'neutral', + 'unknown' => 'neutral', + 'unsupported' => 'neutral', + nil => 'neutral' + }.each do |code, expected| + it "maps CheckCode #{code.inspect} to #{expected}" do + cc = double('CheckCode', code: code) + expect(described_class.terminal_status_for_check_code(cc)).to eq(expected) + end + end + + it 'is neutral when given a value that does not respond to #code' do + expect(described_class.terminal_status_for_check_code(:safe)).to eq('neutral') + end + end +end diff --git a/spec/lib/msf/core/reporting/reporter_spec.rb b/spec/lib/msf/core/reporting/reporter_spec.rb new file mode 100644 index 0000000000000..edc8776ca3fba --- /dev/null +++ b/spec/lib/msf/core/reporting/reporter_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Msf::Reporting::Reporter do + describe '#initialize' do + it 'defaults the driver to :in_memory when no framework is given' do + reporter = described_class.new + expect(reporter.driver).to eq(:in_memory) + end + + it 'accepts an explicit driver from the known set' do + Msf::Reporting::Reporter::KNOWN_DRIVERS.each do |driver| + expect(described_class.new(driver: driver).driver).to eq(driver) + end + end + + it 'rejects an unknown driver with Msf::Reporting::Error' do + expect do + described_class.new(driver: :sqlite) + end.to raise_error(Msf::Reporting::Error, /unknown reporting driver/) + end + + it 'reads the driver from framework.db.driver when not given explicitly' do + db = double('db', driver: :db_manager) + framework = double('framework', db: db) + reporter = described_class.new(framework: framework, backend: double('backend')) + expect(reporter.driver).to eq(:db_manager) + end + + it 'normalises the legacy "postgresql" driver string to :db_manager' do + db = double('db', driver: 'postgresql') + framework = double('framework', db: db) + reporter = described_class.new(framework: framework, backend: double('backend')) + expect(reporter.driver).to eq(:db_manager) + end + + it 'normalises the legacy "http" driver string to :http' do + db = double('db', driver: 'http') + framework = double('framework', db: db) + reporter = described_class.new(framework: framework, backend: double('backend')) + expect(reporter.driver).to eq(:http) + end + + it 'builds an InMemoryBackend when the driver is :in_memory and no framework is given' do + reporter = described_class.new(driver: :in_memory) + expect(reporter.backend).to be_a(Msf::Reporting::Backends::InMemoryBackend) + end + + it 'builds a DbManagerBackend when the driver is :db_manager' do + framework = double('framework', db: double('db', driver: :db_manager)) + reporter = described_class.new(framework: framework, driver: :db_manager) + expect(reporter.backend).to be_a(Msf::Reporting::Backends::DbManagerBackend) + end + + it 'leaves backend nil for the :http driver' do + reporter = described_class.new(driver: :http) + expect(reporter.backend).to be_nil + end + + it 'accepts an explicit backend instance overriding driver-based construction' do + injected = double('backend') + reporter = described_class.new(driver: :in_memory, backend: injected) + expect(reporter.backend).to be(injected) + end + end + + describe 'stub single-entity methods' do + subject(:reporter) { described_class.new } + + %i[report_service report_vuln report_note report_loot].each do |method| + it "#{method} returns a Skipped(:not_implemented) result tagged with the right entity_type" do + result = reporter.public_send(method, foo: :bar) + expect(result).to be_a(Msf::Reporting::Results::Skipped) + expect(result.reason).to eq(:not_implemented) + expect(result.entity_type).to be_a(Symbol) + end + end + end + + describe '#report_host' do + it 'delegates to the resolved backend' do + injected = double('backend') + expect(injected).to receive(:report_host).with(address: '192.0.2.10').and_return(:sentinel) + + reporter = described_class.new(driver: :in_memory, backend: injected) + expect(reporter.report_host(address: '192.0.2.10')).to eq(:sentinel) + end + + it 'returns Skipped(:not_implemented) when the resolved backend is nil (e.g. :http)' do + reporter = described_class.new(driver: :http) + result = reporter.report_host(address: '192.0.2.10') + expect(result).to be_a(Msf::Reporting::Results::Skipped) + expect(result.reason).to eq(:not_implemented) + expect(result.entity_type).to eq(:host) + end + end + + describe '#report_session' do + it 'returns a Compound result while compound wiring is unimplemented' do + result = described_class.new.report_session(host: {}, service: {}, session: nil) + expect(result).to be_a(Msf::Reporting::Results::Compound) + expect(result.steps).to eq([]) + expect(result.overall).to eq(:skipped_db_inactive) + end + end + + describe '#current_execution' do + it 'returns nil from the stub' do + expect(described_class.new.current_execution).to be_nil + end + end +end diff --git a/spec/lib/msf/core/reporting/results_spec.rb b/spec/lib/msf/core/reporting/results_spec.rb new file mode 100644 index 0000000000000..47fb48e27e540 --- /dev/null +++ b/spec/lib/msf/core/reporting/results_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Msf::Reporting::Results do + describe Msf::Reporting::Results::Persisted do + subject(:persisted) { described_class.new(entity_type: :host, row_id: 7, row: :a_row) } + + it 'exposes its kwargs' do + expect(persisted.entity_type).to eq(:host) + expect(persisted.row_id).to eq(7) + expect(persisted.row).to eq(:a_row) + end + + it 'defaults touched to false' do + expect(persisted.touched).to be(false) + end + + it 'accepts touched: true to mark the find-or-create / touched-by case' do + result = described_class.new(entity_type: :host, row_id: 1, touched: true) + expect(result.touched).to be(true) + end + + it 'reports the correct predicates' do + expect(persisted).to be_persisted + expect(persisted).not_to be_skipped + expect(persisted).not_to be_failed + end + + it 'requires entity_type and row_id' do + expect { described_class.new(entity_type: :host) }.to raise_error(ArgumentError) + expect { described_class.new(row_id: 1) }.to raise_error(ArgumentError) + end + end + + describe Msf::Reporting::Results::Skipped do + subject(:skipped) { described_class.new(entity_type: :host, reason: :db_inactive) } + + it 'carries entity_type and reason' do + expect(skipped.entity_type).to eq(:host) + expect(skipped.reason).to eq(:db_inactive) + end + + it 'reports the correct predicates' do + expect(skipped).to be_skipped + expect(skipped).not_to be_persisted + expect(skipped).not_to be_failed + end + end + + describe Msf::Reporting::Results::Failed do + let(:error) { Msf::Reporting::BackendError.new('boom') } + + subject(:failed) { described_class.new(entity_type: :host, error: error) } + + it 'carries entity_type and error' do + expect(failed.entity_type).to eq(:host) + expect(failed.error).to be(error) + end + + it 'reports the correct predicates' do + expect(failed).to be_failed + expect(failed).not_to be_persisted + expect(failed).not_to be_skipped + end + end + + describe Msf::Reporting::Results::SkippedDependencyFailed do + subject(:dep) { described_class.new(entity_type: :service, parent: :host) } + + it 'carries entity_type and parent step name' do + expect(dep.entity_type).to eq(:service) + expect(dep.parent).to eq(:host) + end + + it 'is treated as a skip, not a failure' do + expect(dep).to be_skipped + expect(dep).not_to be_failed + end + end + + describe Msf::Reporting::Results::Compound do + let(:host_step) { Msf::Reporting::Results::Persisted.new(entity_type: :host, row_id: 1) } + let(:service_step) { Msf::Reporting::Results::Persisted.new(entity_type: :service, row_id: 2) } + + it 'requires steps and overall' do + expect { described_class.new(steps: []) }.to raise_error(ArgumentError) + expect { described_class.new(overall: :ok) }.to raise_error(ArgumentError) + end + + it 'rejects unknown overall values' do + expect do + described_class.new(steps: [], overall: :bogus) + end.to raise_error(ArgumentError, /overall must be one of/) + end + + it 'accepts every documented overall value' do + %i[ok partial failed skipped_db_inactive].each do |overall| + expect do + described_class.new(steps: [host_step], overall: overall) + end.not_to raise_error + end + end + + it 'exposes overall predicates' do + ok = described_class.new(steps: [host_step, service_step], overall: :ok) + partial = described_class.new(steps: [host_step], overall: :partial) + failed = described_class.new(steps: [host_step], overall: :failed) + skipped = described_class.new(steps: [], overall: :skipped_db_inactive) + + expect(ok).to be_ok + expect(partial).to be_partial + expect(failed).to be_failed + expect(skipped).to be_skipped_db_inactive + end + end +end diff --git a/spec/lib/msf/core/reporting/schema_present_spec.rb b/spec/lib/msf/core/reporting/schema_present_spec.rb new file mode 100644 index 0000000000000..f5e04cb21d695 --- /dev/null +++ b/spec/lib/msf/core/reporting/schema_present_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Framework-side smoke spec. Confirms the `metasploit_data_models` +# release that ships the reporting refactor's foundational tables is +# wired up correctly: the AR classes load, their tables exist, and +# the documented columns are present. +RSpec.describe 'Reporting schema presence', type: :model do + describe 'Mdm::ModuleExecution' do + subject(:klass) { Mdm::ModuleExecution } + + it 'is loadable as an ApplicationRecord descendant' do + expect(klass.ancestors).to include(ApplicationRecord) + end + + it 'is bound to the module_executions table' do + expect(klass.table_name).to eq('module_executions') + expect(ApplicationRecord.connection.table_exists?('module_executions')).to be(true) + end + + it 'exposes the documented columns' do + column_names = klass.column_names + expect(column_names).to include( + 'id', + 'workspace_id', + 'module_reference_name', + 'module_type', + 'kind', + 'options_snapshot', + 'originating_ui', + 'originating_user_id', + 'originating_token_ref', + 'parent_execution_id', + 'started_at', + 'ended_at', + 'terminal_status', + 'failure_reason', + 'failure_message', + 'single_entity_failure_count', + 'last_single_entity_errors', + 'created_at', + 'updated_at' + ) + end + + it 'exposes the documented enum constants' do + expect(klass::KINDS).to match_array(%w[run check import direct_write]) + expect(klass::MODULE_TYPES).to match_array( + %w[exploit auxiliary post payload encoder evasion nop external] + ) + expect(klass::ORIGINATING_UIS).to match_array( + %w[console rpc json_rpc mcp external import plugin autocheck] + ) + expect(klass::TERMINAL_STATUSES).to match_array( + %w[running success neutral expected_failure unhandled_exception] + ) + end + + it 'declares the documented associations' do + assoc_names = klass.reflect_on_all_associations.map(&:name) + expect(assoc_names).to include( + :workspace, + :originating_user, + :parent_execution, + :children, + :execution_errors, + :events + ) + end + end + + describe 'Mdm::ModuleExecutionError' do + subject(:klass) { Mdm::ModuleExecutionError } + + it 'is loadable as an ApplicationRecord descendant' do + expect(klass.ancestors).to include(ApplicationRecord) + end + + it 'is bound to the module_execution_errors table' do + expect(klass.table_name).to eq('module_execution_errors') + expect(ApplicationRecord.connection.table_exists?('module_execution_errors')).to be(true) + end + + it 'exposes the documented columns' do + expect(klass.column_names).to include( + 'id', + 'module_execution_id', + 'exception_class', + 'message', + 'backtrace', + 'lifecycle_phase', + 'failure_reason', + 'occurred_at', + 'created_at', + 'updated_at' + ) + end + + it 'exposes the lifecycle-phase enum constant' do + expect(klass::LIFECYCLE_PHASES).to match_array( + %w[setup check exploit cleanup post run] + ) + end + end + + describe 'Mdm::ModuleExecutionEvent' do + subject(:klass) { Mdm::ModuleExecutionEvent } + + it 'is loadable as an ApplicationRecord descendant' do + expect(klass.ancestors).to include(ApplicationRecord) + end + + it 'is bound to the module_execution_events table' do + expect(klass.table_name).to eq('module_execution_events') + expect(ApplicationRecord.connection.table_exists?('module_execution_events')).to be(true) + end + + it 'exposes the documented columns' do + expect(klass.column_names).to include( + 'id', + 'module_execution_id', + 'name', + 'payload', + 'occurred_at', + 'created_at', + 'updated_at' + ) + end + end +end diff --git a/spec/lib/msf/core/reporting/test_helper_spec.rb b/spec/lib/msf/core/reporting/test_helper_spec.rb new file mode 100644 index 0000000000000..6c234e0dc8ebb --- /dev/null +++ b/spec/lib/msf/core/reporting/test_helper_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'support/reporting/test_helper' + +RSpec.describe Msf::Reporting::TestHelper do + describe 'inclusion' do + it 'exposes a fresh InMemoryBackend as #reporter via let' do + group = RSpec::Core::ExampleGroup.describe('inclusion smoke') do + include Msf::Reporting::TestHelper + end + + group.run + + example = group.new + expect(example.reporter).to be_a(Msf::Reporting::Backends::InMemoryBackend) + expect(example.reporter.calls).to be_empty + end + end + + describe 'have_reported matcher' do + let(:backend) { Msf::Reporting::Backends::InMemoryBackend.new } + + it 'matches when an entity of the given type was reported' do + backend.report_host(address: '192.0.2.10') + expect(backend).to have_reported(:host) + end + + it 'fails to match when no call of that entity_type was made' do + backend.report_host(address: '192.0.2.10') + expect(backend).not_to have_reported(:vuln) + end + + it 'matches on a subset of fields' do + backend.report_service(host: 1, port: 80, proto: 'tcp', name: 'http') + expect(backend).to have_reported(:service, port: 80) + expect(backend).to have_reported(:service, proto: 'tcp', name: 'http') + end + + it 'does not match when a specified field differs from the recorded call' do + backend.report_host(address: '192.0.2.10') + expect(backend).not_to have_reported(:host, address: '192.0.2.99') + end + + it 'does not match when a specified field is absent from the recorded call' do + backend.report_host(address: '192.0.2.10') + expect(backend).not_to have_reported(:host, name: 'absent') + end + + describe 'count modifiers' do + before do + backend.report_host(address: '192.0.2.10') + backend.report_host(address: '192.0.2.11') + backend.report_host(address: '192.0.2.12') + end + + it 'enforces exactly(N).times' do + expect(backend).to have_reported(:host).exactly(3).times + expect(backend).not_to have_reported(:host).exactly(2).times + end + + it 'enforces at_least(N).time(s)' do + expect(backend).to have_reported(:host).at_least(2).times + expect(backend).not_to have_reported(:host).at_least(4).times + end + + it 'enforces at_most(N).time(s)' do + expect(backend).to have_reported(:host).at_most(3).times + expect(backend).not_to have_reported(:host).at_most(2).times + end + + it 'combines field-subset matching with count modifiers' do + expect(backend).to have_reported(:host, address: '192.0.2.10').exactly(1).time + end + end + + describe 'ordering' do + it 'preserves recording order on the underlying #calls list' do + backend.report_host(address: '192.0.2.10') + backend.report_service(host: 1, port: 80, proto: 'tcp') + backend.report_host(address: '192.0.2.11') + + entity_order = backend.calls.map { |c| c[:entity_type] } + expect(entity_order).to eq(%i[host service host]) + end + end + end +end diff --git a/spec/support/reporting/shared_examples.rb b/spec/support/reporting/shared_examples.rb new file mode 100644 index 0000000000000..166a6918a7809 --- /dev/null +++ b/spec/support/reporting/shared_examples.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +# Shared examples enforcing the cross-backend parity contract. Every +# reporting backend MUST satisfy this suite identically; differences +# in the wire shape are bugs. The suite is split into per-method +# shared example groups so backends mid-migration can opt into just +# the parts they support. +# +# Each including spec MUST define: +# let(:backend) { ... } # backend under test +# def simulate_db_inactive!; ...; end # put backend into :db_inactive +# def inject_failure(method, error); ...; end # next call to method returns Failed(error) + +RSpec.shared_examples 'a reporting backend report_host' do + describe '#report_host' do + it 'returns Persisted with entity_type :host on a valid call' do + result = backend.report_host(address: '192.0.2.10') + + expect(result).to be_a(Msf::Reporting::Results::Persisted) + expect(result.entity_type).to eq(:host) + expect(result.row_id).to be_a(Integer) + expect(result.persisted?).to be(true) + end + + context 'when the DB is inactive' do + before { simulate_db_inactive! } + + it 'returns Skipped(reason: :db_inactive)' do + result = backend.report_host(address: '192.0.2.10') + + expect(result).to be_a(Msf::Reporting::Results::Skipped) + expect(result.entity_type).to eq(:host) + expect(result.reason).to eq(:db_inactive) + end + end + + context 'when persistence fails' do + before do + inject_failure(:report_host, Msf::Reporting::BackendError.new('forced')) + end + + it 'returns Failed carrying a typed BackendError (does NOT raise)' do + expect do + @result = backend.report_host(address: '192.0.2.10') + end.not_to raise_error + + expect(@result).to be_a(Msf::Reporting::Results::Failed) + expect(@result.entity_type).to eq(:host) + expect(@result.error).to be_a(Msf::Reporting::BackendError) + end + end + end +end + +RSpec.shared_examples 'a reporting backend report_service' do + describe '#report_service' do + it 'returns Persisted with entity_type :service on a valid call' do + result = backend.report_service(host: 1, port: 80, proto: 'tcp') + + expect(result).to be_a(Msf::Reporting::Results::Persisted) + expect(result.entity_type).to eq(:service) + end + end +end + +RSpec.shared_examples 'a reporting backend single-entity result hierarchy' do + describe 'result hierarchy parity' do + it 'never returns a value outside the documented Result hierarchy for single-entity calls' do + result = backend.report_note(host: 1, type: 'host.os', data: 'linux') + expect([ + Msf::Reporting::Results::Persisted, + Msf::Reporting::Results::Skipped, + Msf::Reporting::Results::Failed + ]).to include(result.class) + end + end +end + +RSpec.shared_examples 'a reporting backend report_session' do + describe '#report_session (compound)' do + let(:session) { Object.new } + + it 'returns a Compound with steps in the documented order' do + result = backend.report_session( + host: { address: '192.0.2.10' }, + service: { port: 445, proto: 'tcp' }, + session: session + ) + + expect(result).to be_a(Msf::Reporting::Results::Compound) + expect(result.overall).to eq(:ok) + expect(result.steps.map(&:entity_type)).to eq(%i[host service session]) + end + + it 'returns :skipped_db_inactive when the DB is inactive (raise_on_failure has no effect)' do + simulate_db_inactive! + + result = backend.report_session( + host: { address: '192.0.2.10' }, + service: { port: 445, proto: 'tcp' }, + session: session + ) + + expect(result.overall).to eq(:skipped_db_inactive) + end + end +end + +# Umbrella suite: every method shared group above. Backends that have +# completed migration of every entity in scope include this; backends +# mid-migration cherry-pick the per-method groups they support. +RSpec.shared_examples 'a reporting backend' do + it_behaves_like 'a reporting backend report_host' + it_behaves_like 'a reporting backend report_service' + it_behaves_like 'a reporting backend single-entity result hierarchy' + it_behaves_like 'a reporting backend report_session' +end diff --git a/spec/support/reporting/test_helper.rb b/spec/support/reporting/test_helper.rb new file mode 100644 index 0000000000000..116560ff357c4 --- /dev/null +++ b/spec/support/reporting/test_helper.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +module Msf + module Reporting + # RSpec mixin giving a framework / in-tree library spec a fresh in-memory + # +Msf::Reporting::Backends::InMemoryBackend+ as +reporter+ and a + # +have_reported+ matcher for asserting on recorded calls. + # + # Test-only artifact: lives under +spec/support/reporting/+ and is NOT + # shipped with the framework gem. Audience is framework and in-tree + # library specs only; per-module specs and downstream gem consumers + # are out of scope. + # + # @example + # require 'support/reporting/test_helper' + # + # RSpec.describe SomeFrameworkLibraryClass do + # include Msf::Reporting::TestHelper + # + # it 'reports the discovered host' do + # reporter.report_host(address: '192.0.2.10') + # expect(reporter).to have_reported(:host, address: '192.0.2.10') + # end + # end + module TestHelper + def self.included(base) + base.let(:reporter) { Msf::Reporting::Backends::InMemoryBackend.new } if base.respond_to?(:let) + end + end + end +end + +if defined?(RSpec::Matchers) + # @!method have_reported(entity_type, **expected_fields) + # + # Matches when the actual +InMemoryBackend+ has at least one recorded + # call whose +entity_type+ equals +entity_type+ and whose +kwargs+ + # superset-match the provided +expected_fields+ (callers supply a + # subset of fields). + # + # Modifiers: + # .exactly(n).time / .times → assert an exact match count. + # .at_least(n).time(s) → assert minimum match count. + # .at_most(n).time(s) → assert maximum match count. + RSpec::Matchers.define :have_reported do |entity_type, **expected_fields| + match do |reporter| + @entity_type = entity_type + @expected_fields = expected_fields + @all_calls = reporter.respond_to?(:calls) ? reporter.calls : [] + @matching = @all_calls.select do |call| + next false unless call[:entity_type] == entity_type + + expected_fields.all? { |k, v| call[:kwargs].key?(k) && call[:kwargs][k] == v } + end + + if @exact_count + @matching.size == @exact_count + elsif @min_count + @matching.size >= @min_count + elsif @max_count + @matching.size <= @max_count + else + @matching.any? + end + end + + chain :exactly do |n| + @exact_count = n + end + + chain :at_least do |n| + @min_count = n + end + + chain :at_most do |n| + @max_count = n + end + + # Sugar so the call reads naturally: `.exactly(1).time` / + # `.exactly(2).times`. These chains carry no semantics on their own. + chain(:time) {} + chain(:times) {} + + failure_message do |_reporter| + base = "expected reporter to have reported #{@entity_type.inspect}" + base += " with fields #{@expected_fields.inspect}" unless @expected_fields.empty? + base += " (matched #{@matching.size}, expected #{@exact_count})" if @exact_count + base += "\nrecorded calls:\n #{format_calls.join("\n ")}" + base + end + + failure_message_when_negated do |_reporter| + "expected reporter NOT to have reported #{@entity_type.inspect}" \ + "#{@expected_fields.empty? ? '' : " with fields #{@expected_fields.inspect}"}" \ + " but matched #{@matching.size} call(s)" + end + + define_method(:format_calls) do + @all_calls.map { |c| "#{c[:method]} #{c[:kwargs].inspect}" } + end + end +end