Skip to content

Reporting refactor - first iteration#21576

Open
cdelafuente-r7 wants to merge 3 commits into
rapid7:masterfrom
cdelafuente-r7:reporting-refactoring
Open

Reporting refactor - first iteration#21576
cdelafuente-r7 wants to merge 3 commits into
rapid7:masterfrom
cdelafuente-r7:reporting-refactoring

Conversation

@cdelafuente-r7

@cdelafuente-r7 cdelafuente-r7 commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Description

This is the first iteration of the Metasploit reporting refactor. This project aims for a structured refactor of Metasploit reporting into a typed, backend-agnostic architecture that creates auditable execution records and consistent failure semantics across all reporting surfaces. The current system works in many success paths but has inconsistent behavior under failure and uneven attribution across module types and transports. This initiative addresses those reliability gaps while preserving delivery safety through additive, non-breaking iterations.

Current reporting behavior has three systemic issues:

  1. Failure behavior can be inconsistent or too implicit, reducing operator trust in audit completeness.
  2. Writer surfaces are fragmented (modules, RPC, MCP, importers, plugins, external bridge), causing contract drift.

Breaking Changes

The refactor is designed to be additive, so most changes are new surface area rather than removals. The items below are the places where an existing signature, symbol, behavior, or DB constraint actually changes in a way that could break a caller.

  1. Msf::Simple::Auxiliary — public class-method changes
  • job_cleanup_proc renamed to notify_job_complete.
  • notify_job_complete no longer invokes mod.cleanup. Cleanup now runs inside job_run_proc.
  • derive_terminal_status arity changed: the fourth unhandled_exception positional parameter was removed.
  1. Msf::Simple::Exploit — internal helper arity change
  • finalize_exploit_execution dropped its third unhandled_exception positional argument and reads the ivar instead.
  • job_check_proc now unconditionally spawns/finalizes a kind: 'check' Mdm::ModuleExecution and hard-codes Failure::Unknown on the unhandled path.
  1. Msf::Exploit::Remote::AutoCheck — return-shape change
  • run_with_check_execution now returns [check_code, check_execution], where it previously returned only the check_code.
  1. Cleanup timing (aux) — observable behavior change
  • Aux mod.cleanup runs earlier than before. It used to fire from job_cleanup_proc after on_module_complete; it now fires inside job_run_proc (still exactly once, still on every rescue branch).
  1. fail_with — semantics widened across module types
  • Every fail_with override now records an Mdm::ModuleExecutionError row via record_failure! and then re-raises the Msf::Module::Failure exception with a RECORDED_EXCEPTION_IVAR set on it, so the outer handle_exception / simple-wrapper rescues will short-circuit. Affected files: lib/msf/core/{exploit,auxiliary,post,evasion,module}.rb, scanner.rb.
  1. handle_exception in exploit.rb
  • After framework.events.on_module_error, the method now also calls capture_exception!(self, e, failure_reason: fail_reason) and mark_module_unhandled_exception(self) unless the exception is an Msf::Exploit::Failed.
  1. Thread-local state consumed by lifecycle wrappers
  • Msf::Reporting::CurrentExecution and Msf::Reporting::CurrentPhase install thread-local state around run_simple / exploit_simple / check_simple / payload_generator.wrap_with_execution_lifecycle / with_phase_setup / with_phase_cleanup.
  1. Framework-side schema.rb mirroring
  • schema.rb now mirrors the new MDM tables (module_executions, module_execution_errors, module_execution_events) with the check_code / check_message columns and two CHECK constraints.

Reviewer Notes

IMPORTANT This needs this PR to be landed first.

This is the first iteration of this project and only a few features were implemented so far. ( e.g. hosts, services, vulns, notes, loots,etc. data models are not linked to module executions yet).

Verification Steps

Validate the new Mdm::ModuleExecution / Mdm::ModuleExecutionError reporting
by running two private test modules and inspecting the DB after each scenario.

Setup

Start the DB and run the migrations:

bundle exec ./msfdb init      # first time or ./msfdb reinit
bundle exec./msfdb start
bundle exec rake db:migrate

Install the test modules

Copy the two files from Test module source into:

~/.msf4/modules/auxiliary/test/reporting/reporting_aux.rb
~/.msf4/modules/exploits/test/reporting/reporting_exploit.rb

Start msfconsole, confirm db_status shows "Connected", then reload_all.

Inspecting the DB

Save this resource file:

cat > ~/.msf4/reporting-check.rc <<'RC'
<ruby>
puts "\n== Last 5 Mdm::ModuleExecution rows =="
Mdm::ModuleExecution.order(:id).last(5).each do |e|
  puts format(
    "#%-4d %-9s %-45s kind=%-5s status=%-19s iface=%-9s parent=%-4s reason=%s msg=%s check_code=%s check_msg=%s",
    e.id, e.module_type, e.module_reference_name, e.kind, e.terminal_status,
    e.originating_interface, e.parent_execution_id.to_s,
    e.failure_reason.to_s, e.failure_message.to_s,
    e.check_code.to_s, e.check_message.to_s
  )
end

puts "\n== Last 10 Mdm::ModuleExecutionError rows =="
Mdm::ModuleExecutionError.order(:id).last(10).each do |err|
  puts format(
    "#%-4d exec=%-4d phase=%-7s class=%-30s reason=%-15s msg=%s",
    err.id, err.module_execution_id, err.lifecycle_phase,
    err.exception_class.to_s, err.failure_reason.to_s, err.message.to_s
  )
end
</ruby>
RC

Run it between test cases:

msf6 > resource ~/.msf4/reporting-check.rc

Test matrix

For every scenario:

  1. Start from the pristine module files (all #-commented error lines
    commented).
  2. Uncomment the ONE line named in the scenario.
  3. reload, run the command, then resource ~/.msf4/reporting-check.rc.

Every non-happy-path scenario produces exactly 1 new execution row + 1 new
error row
unless stated otherwise. Fields not listed do not matter.

Legend:

  • status = terminal_status
  • reason = failure_reason
  • msg = failure_message (on execution) or message (on error)
  • phase = lifecycle_phase
  • class = exception_class

Auxiliary — use auxiliary/test/reporting/reporting_aux

  1. Happy path — run
  • Execution: kind=run, status=success, reason=NULL, msg=NULL, parent=NULL.
  • No error row.
  1. check on aux — check
  • Execution: kind=check, status=neutral, check_code=detected,
    check_message="reporting test: detected (default)".
  • No error row.
  1. fail_with in run — uncomment fail_with in #run, then run
  • Execution: status=expected_failure, reason=unexpected-reply,
    msg="boom in run (fail_with)".
  • Error: phase=run, class=NULL, reason=unexpected-reply,
    msg="boom in run (fail_with)".
  1. Unhandled in run — uncomment raise in #run, then run
  • Execution: status=unhandled_exception, reason=unknown,
    msg="boom in run (unhandled)".
  • Error: phase=run, class=RuntimeError, reason=unknown,
    msg="boom in run (unhandled)", backtrace non-empty.
  1. fail_with in setup — uncomment fail_with in #setup, then run
  • [run] running must not appear in console output.
  • Execution: status=expected_failure, reason=bad-config,
    msg="boom in setup (fail_with)".
  • Error: phase=setup, class=NULL, reason=bad-config.
  1. Unhandled in setup — uncomment raise in #setup, then run
  • [run] running must not appear.
  • Execution: status=unhandled_exception, reason=unknown,
    msg="boom in setup (unhandled)".
  • Error: phase=setup, class=RuntimeError, reason=unknown, backtrace non-empty.
  1. fail_with in cleanup — uncomment fail_with in #cleanup, then run
  • [run] success must appear before the failure.
  • Execution: status=expected_failure, reason=unknown,
    msg="boom in cleanup (fail_with)".
  • Error: phase=cleanup, class=NULL, reason=unknown.
  1. Unhandled in cleanup — uncomment raise in #cleanup, then run
  • Execution: status=unhandled_exception, reason=unknown,
    msg="boom in cleanup (unhandled)".
  • Error: phase=cleanup, class=RuntimeError, reason=unknown, backtrace non-empty.

Exploit — use exploit/test/reporting/reporting_exploit

Every exploit scenario except 16. produces TWO execution rows:

  • child (kind=check, iface=autocheck, parent=<parent-id>)
  • parent (kind=run, iface=console, parent=NULL)
  1. Happy path — run
  • Child: status=success, check_code=appears,
    check_message="reporting test: appears (default)".
  • Parent: status=expected_failure, reason=payload-failed,
    msg="No session created", check_code=NULL.
  • No error rows.
  1. Standalone checkcheck

One execution row: kind=check, iface=console, parent=NULL,
status=success, check_code=appears,
check_message="reporting test: appears (default)". No error row.

Alternate returns (uncomment the matching return CheckCode::… line in
#check, one at a time):

uncommented line status check_code
return CheckCode::Vulnerable('...') success vulnerable
return CheckCode::Appears('...') (default) success appears
return CheckCode::Detected('...') neutral detected
return CheckCode::Safe('...') neutral safe
return CheckCode::Unknown('...') neutral unknown
  1. fail_with in check — uncomment fail_with in #check, then run
  • Child: status=expected_failure, reason=unexpected-reply,
    msg="boom in check (fail_with)", check_code=NULL.
  • Parent: status=expected_failure, reason=unexpected-reply,
    msg="boom in check (fail_with)".
  • ONE error row on the child: phase=check, class=NULL,
    reason=unexpected-reply, msg="boom in check (fail_with)".
  1. AutoCheck aborts on non-vulnerable — uncomment one of Safe/Unsupported/Unknown return in #check, then run
  • Child: status=neutral, check_code=<safe|unsupported|unknown>.
  • Parent: status=expected_failure, reason= as follows.
  • ONE error row on the child: phase=check, class=NULL, reason= as follows.
CheckCode parent + error reason
Safe not-vulnerable
Unsupported bad-config
Unknown unknown
  1. Unhandled in check — uncomment raise in #check, then run
  • Child: status=unhandled_exception, reason=unknown,
    msg="boom in check (unhandled)".
  • Parent: status=unhandled_exception, reason=unknown,
    msg="boom in check (unhandled)".
  • ONE error row on the child: phase=check, class=RuntimeError,
    reason=unknown, backtrace non-empty.
  1. fail_with in exploit — uncomment fail_with in #exploit, then run
  • Child: status=success, check_code=appears.
  • Parent: status=expected_failure, reason=payload-failed,
    msg="boom in exploit (fail_with)".
  • ONE error row on the parent: phase=exploit, class=NULL,
    reason=payload-failed.
  1. Unhandled in exploit — uncomment raise in #exploit, then run
  • Child: status=success, check_code=appears.
  • Parent: status=unhandled_exception, reason=unknown,
    msg="boom in exploit (unhandled)".
  • ONE error row on the parent: phase=exploit, class=RuntimeError,
    reason=unknown, backtrace non-empty.
  1. Unhandled in setup — uncomment raise in #setup, then run

Only ONE execution row (the parent). No child row.

  • Parent: status=unhandled_exception, reason=unknown,
    msg="boom in setup (unhandled)", check_code=NULL.
  • ONE error row on the parent: phase=setup, class=RuntimeError,
    reason=unknown, backtrace non-empty.
  1. Unhandled in cleanup — uncomment raise in #cleanup, then run
  • Child: status=success, check_code=appears.
  • Parent: status=unhandled_exception, reason=unknown.
  • ONE error row on the parent: phase=cleanup, class=RuntimeError,
    reason=unknown, backtrace non-empty.

  1. msfvenom — encoder + NOP wrap

From a shell:

./msfvenom -p linux/x64/exec CMD=id -e x64/xor -i 3 -f raw > /dev/null

Expected: one or more new execution rows with iface=console, kind=run,
status=success, module_reference_name matching an encoder (x64/xor) and,
if the payload required NOPs, a NOP (x64/simple).

  1. DB inactive
msf6 > db_disconnect
msf6 > use auxiliary/test/reporting/reporting_aux
msf6 auxiliary(...) > run

Module runs and prints normally, no exception raised, and no new rows in
module_executions. Reconnect with db_connect (or restart msfconsole)
before continuing.

Test module source

~/.msf4/modules/auxiliary/test/reporting/reporting_aux.rb

# frozen_string_literal: true

class MetasploitModule < Msf::Auxiliary
  def initialize(info = {})
    super(
      update_info(
        info,
        'Name'        => 'Reporting Refactor Aux Test',
        'Description' => 'Exercises the reporting lifecycle. Does no real work.'
      )
    )

    register_options([
      Opt::RHOSTS('127.0.0.1'),
      Opt::RPORT(1234)
    ])
  end

  def setup
    print_status('[setup] running')
    #fail_with(Failure::BadConfig, 'boom in setup (fail_with)')
    #raise 'boom in setup (unhandled)'
  end

  def check
    print_status('[check] running')
    #return Msf::Exploit::CheckCode::Vulnerable('reporting test: vulnerable')
    #return Msf::Exploit::CheckCode::Appears('reporting test: appears')
    #return Msf::Exploit::CheckCode::Safe('reporting test: safe')
    #return Msf::Exploit::CheckCode::Unknown('reporting test: unknown')
    #fail_with(Failure::UnexpectedReply, 'boom in check (fail_with)')
    #raise 'boom in check (unhandled)'
    Msf::Exploit::CheckCode::Detected('reporting test: detected (default)')
  end

  def run
    print_status('[run] running')
    #fail_with(Failure::UnexpectedReply, 'boom in run (fail_with)')
    #raise 'boom in run (unhandled)'
    print_good('[run] success')
  end

  def cleanup
    print_status('[cleanup] running')
    #fail_with(Failure::Unknown, 'boom in cleanup (fail_with)')
    #raise 'boom in cleanup (unhandled)'
    super
  end
end

~/.msf4/modules/exploits/test/reporting/reporting_exploit.rb

# frozen_string_literal: true

class MetasploitModule < Msf::Exploit::Remote
  Rank = NormalRanking
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name'           => 'Reporting Refactor Exploit Test',
        'Description'    => 'Exercises the reporting lifecycle for exploits + AutoCheck.',
        'Platform'       => 'unix',
        'Arch'           => ARCH_CMD,
        'DefaultOptions' => { 'DisablePayloadHandler' => true },
        'Targets'        => [['Automatic', {}]],
        'DefaultTarget'  => 0
      )
    )

    register_options([
      Opt::RHOST('127.0.0.1'),
      Opt::RPORT(1234)
    ])
  end

  def check
    print_status('[check] running')
    #return CheckCode::Vulnerable('reporting test: vulnerable')
    #return CheckCode::Detected('reporting test: detected')
    #return CheckCode::Safe('reporting test: safe')
    #return CheckCode::Unknown('reporting test: unknown')
    #fail_with(Failure::UnexpectedReply, 'boom in check (fail_with)')
    #raise 'boom in check (unhandled)'
    CheckCode::Appears('reporting test: appears (default)')
  end

  def setup
    super
    print_status('[setup] running')
    #fail_with(Failure::BadConfig, 'boom in setup (fail_with)')
    #raise 'boom in setup (unhandled)'
  end

  def exploit
    print_status('[exploit] running')
    #fail_with(Failure::PayloadFailed, 'boom in exploit (fail_with)')
    #raise 'boom in exploit (unhandled)'
    print_good('[exploit] done (no session opened)')
  end

  def cleanup
    print_status('[cleanup] running')
    #fail_with(Failure::Unknown, 'boom in cleanup (fail_with)')
    #raise 'boom in cleanup (unhandled)'
    super
  end
end

AI Usage Disclosure

AI was used for this project (Copilot with Claude Opus 4.7 model) to help for the code analysis, the design and the implementation.

Pre-Submission Checklist

  • Ran rubocop on new files with no new offenses (net new files only)
  • Ran msftidy on changed module files with no new offenses (modules only)
  • Ran msftidy_docs on changed documentation files with no new offenses (documentation files only)
  • Included a corresponding documentation markdown file in documentation/modules (new modules only)
  • No sensitive information (IP addresses, credentials, API keys, hashes) in code or documentation
  • Tested on the target environment specified in the Environment section above
  • Included RSpec tests for library changes (encouraged for lib/ changes)
  • Read the CONTRIBUTING.md and module acceptance guidelines

@cdelafuente-r7 cdelafuente-r7 added blocked Blocked by one or more additional tasks rn-enhancement release notes enhancement labels Jun 16, 2026
@cdelafuente-r7 cdelafuente-r7 force-pushed the reporting-refactoring branch 3 times, most recently from fd99dec to 103a23a Compare July 9, 2026 12:52
- 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
@cdelafuente-r7 cdelafuente-r7 force-pushed the reporting-refactoring branch from 103a23a to d07f12b Compare July 10, 2026 16:30
@cdelafuente-r7 cdelafuente-r7 force-pushed the reporting-refactoring branch from d07f12b to 983bbdc Compare July 10, 2026 16:31
@cdelafuente-r7 cdelafuente-r7 moved this from Todo to In Progress in Metasploit Kanban Jul 13, 2026
@cdelafuente-r7 cdelafuente-r7 moved this from In Progress to Todo in Metasploit Kanban Jul 13, 2026
- add `check_code` & `check_message` fields on `ModuleExecution`
- guarantees `mod.cleanup` runs at most once, inside `Msf::Reporting::CurrentExecution.with`
- properly handle `fail_with` inside `#check` corner case
- remove duplicate call to `cleanup` in `self.job_cleanup_proc` and rename it to `self.notify_job_complete`
- always record `Failure::Unknown` for unhandled exceptions
- add `Msf::Reporting::CurrentPhase` to make sure `fail_with` inside setup/cleanup now records phase='setup'
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

blocked Blocked by one or more additional tasks rn-enhancement release notes enhancement

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

2 participants