Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 219 additions & 0 deletions app/models/mdm/module_execution.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
# One invocation of one Metasploit module (or one direct-write
# pseudo-execution. Captures the full forensic record so every
# artifact a module produces or touches can be traced back to a single
# row in `module_executions`.
class Mdm::ModuleExecution < ApplicationRecord
self.table_name = 'module_executions'

#
# CONSTANTS
#

# Why the module was run. `direct_write` is used when an external
# workflow inserts artifacts (hosts, services, creds, loot, ...)
# without invoking a module via the framework.
KINDS = %w[run check import direct_write].freeze

# Module types as exposed by Framework.
MODULE_TYPES = %w[exploit auxiliary post payload encoder evasion nop external].freeze

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think "external" is a module type in the same sense as the others. You can have for example an external exploit or auxiliary module that's implemented in Python. I would suggest removing it or tracking it as a flag so you can note something is an external-exploit, external-auxiliary, etc.


# User interface or programmatic surface that initiated this execution.
ORIGINATING_UIS = %w[console rpc json_rpc mcp external import plugin autocheck].freeze

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
ORIGINATING_UIS = %w[console rpc json_rpc mcp external import plugin autocheck].freeze
ORIGINATORS = %w[console rpc json_rpc mcp external import plugin autocheck module].freeze

I'd suggest adding 'module' as an originator to account for modules running other modules. Additionally since some of these aren't exactly user-interfaces, it may be better to use a slightly more generic name like ORIGINATORS vs ORIGINATING_UIS.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right it's not accurate. I would prefer using ORIGINATING_INTERFACES, which is more programmatic. ORIGINATORS sounds like a user, which seems slighly wrong with things like autocheck. I'm also fine if you disagree, I'll change it to ORIGINATORS, no problem.


# Terminal lifecycle states. `running` is the only non-terminal value
# and is permitted while {#ended_at} is `NULL`.
TERMINAL_STATUSES = %w[running success neutral expected_failure unhandled_exception].freeze
Comment on lines +23 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If unhandled_exception is the inverse of expected_failure, it might be more intuitive to track it as unexpected_failure.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me give more details about these status:

  • expected_failure: the module explicitly declared failure via fail_with. It is an error the module author has anticipated with a proper failure_reason.
  • unhandled_exception: a Ruby exception that escaped the module's rescue chain (NoMethodError, Rex::ConnectionError outside a rescue, etc.). The author did not anticipate this; there's an exception_class and backtrace.

So, unhandled_exception and expected_failure aren't strict inverses. For example, unhandled_exception will help answer question like "which modules are buggy?"


#
# Associations
#

# Workspace that owns this execution. All artifacts produced by the
# execution share this workspace.
#
# @return [Mdm::Workspace]
belongs_to :workspace,
class_name: 'Mdm::Workspace',
inverse_of: :module_executions

# User who initiated this execution, when known. May be `nil` for
# executions originating from non-authenticated surfaces or from
# background workers without a session.
#
# @return [Mdm::User]
# @return [nil] when no user is associated with this execution.
belongs_to :originating_user,
class_name: 'Mdm::User',
optional: true,
inverse_of: :module_executions

# Parent execution that spawned this one (e.g. an exploit's auto-run
# post module). Forms a tree of related executions when present.
#
# @return [Mdm::ModuleExecution]
# @return [nil] for top-level executions.
belongs_to :parent_execution,
class_name: 'Mdm::ModuleExecution',
optional: true,
inverse_of: :children

# Child executions spawned by this execution.
#
# @return [ActiveRecord::Relation<Mdm::ModuleExecution>]
has_many :children,
class_name: 'Mdm::ModuleExecution',
foreign_key: :parent_execution_id,
inverse_of: :parent_execution,
dependent: :nullify

# Errors raised within this execution. Renamed from `errors` to avoid
# colliding with `ActiveModel::Validations#errors`.
#
# @return [ActiveRecord::Relation<Mdm::ModuleExecutionError>]
has_many :execution_errors,
class_name: 'Mdm::ModuleExecutionError',
inverse_of: :module_execution,
dependent: :destroy

# Optional generalized timeline events recorded for this execution.
#
# @return [ActiveRecord::Relation<Mdm::ModuleExecutionEvent>]
has_many :events,
class_name: 'Mdm::ModuleExecutionEvent',
inverse_of: :module_execution,
dependent: :destroy

#
# Attributes
#

# @!attribute [rw] module_reference_name
# Reference name of the module that was executed, e.g.
# `auxiliary/scanner/smb/smb_version`.
#
# @return [String]

# @!attribute [rw] module_type
# One of {MODULE_TYPES}.
#
# @return [String]

# @!attribute [rw] kind
# One of {KINDS}. Defaults to `'run'`.
#
# @return [String]

# @!attribute [rw] options_snapshot
# The datastore options the module was invoked with
#
# @return [Hash]
# @return [nil] when no options were captured.

# @!attribute [rw] originating_ui
# One of {ORIGINATING_UIS}.
#
# @return [String]

# @!attribute [rw] originating_token_ref
# Opaque reference to the auth token / API key used to authorize
# this execution, when applicable. Never the token itself.
#
# @return [String]
# @return [nil] when not applicable.

# @!attribute [rw] started_at
# Wall-clock time when the framework began running the module.
#
# @return [DateTime]

# @!attribute [rw] ended_at
# Wall-clock time when the module finished, regardless of outcome.
# Must be greater than or equal to {#started_at}.
#
# @return [DateTime]
# @return [nil] while the execution is still running.

# @!attribute [rw] terminal_status
# One of {TERMINAL_STATUSES}. Constrained to be `'running'` (or
# `nil`) while {#ended_at} is `nil`, and a non-`running` value
# once {#ended_at} is set.
#
# @return [String]
# @return [nil] while the execution is still running.

# @!attribute [rw] failure_reason
# Short coded reason describing why the module ended in a non-
# success terminal status (e.g. `'no-target'`,
# `'payload-not-supported'`).
#
# @return [String]
# @return [nil] when the execution did not fail.

# @!attribute [rw] failure_message
# Human-readable message describing the failure.
#
# @return [String]
# @return [nil] when the execution did not fail.

# @!attribute [rw] single_entity_failure_count
# How many individually-failed entities (e.g. hosts in a scanner)
# the module recorded during this execution. Used to surface
# per-entity failure summaries without listing every error row.
#
# @return [Integer]
# @return [0] when no per-entity failures were recorded.

# @!attribute [rw] last_single_entity_errors
# Capped sample of the most recent per-entity error
# summaries, suitable for quick inspection in the UI.
#
# @return [Array<Hash>]
# @return [nil] when no per-entity errors were recorded.

# @!attribute [rw] created_at
# When this execution row was inserted.
#
# @return [DateTime]

# @!attribute [rw] updated_at
# Last time this execution row was updated.
#
# @return [DateTime]

#
# Validations
#

validates :module_reference_name, presence: true
validates :module_type, presence: true, inclusion: { in: MODULE_TYPES }
validates :kind, presence: true, inclusion: { in: KINDS }
validates :originating_ui, presence: true, inclusion: { in: ORIGINATING_UIS }
validates :terminal_status, inclusion: { in: TERMINAL_STATUSES, allow_nil: true }
validates :started_at, presence: true
validates :single_entity_failure_count,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }

validate :ended_at_not_before_started_at
validate :terminal_status_consistent_with_ended_at

Metasploit::Concern.run(self)

private

def ended_at_not_before_started_at
return if ended_at.blank? || started_at.blank?
return if ended_at >= started_at

errors.add(:ended_at, 'must be greater than or equal to started_at')
end

def terminal_status_consistent_with_ended_at
if ended_at.nil?
return if terminal_status.nil? || terminal_status == 'running'

errors.add(:terminal_status, "must be 'running' or blank while ended_at is NULL")
elsif terminal_status.nil? || terminal_status == 'running'
errors.add(:terminal_status, 'must be a terminal value once ended_at is set')
end
end
end
88 changes: 88 additions & 0 deletions app/models/mdm/module_execution_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# One row per unhandled exception or explicit failure raised within a
# {Mdm::ModuleExecution}. A single execution may produce many error rows
# when it touches multiple entities (e.g. one row per failed host in
# a scanner sweep).
class Mdm::ModuleExecutionError < ApplicationRecord
self.table_name = 'module_execution_errors'

#
# CONSTANTS
#

# Lifecycle phases of a module run during which an error may be
# captured. `run` covers auxiliary modules, the others map to the
# exploit lifecycle (setup → check → exploit → cleanup) and the
# post-exploitation pass.
LIFECYCLE_PHASES = %w[setup check exploit cleanup post run].freeze

#
# Associations
#

# The module execution this error was raised within.
#
# @return [Mdm::ModuleExecution]
belongs_to :module_execution,
class_name: 'Mdm::ModuleExecution',
inverse_of: :execution_errors

#
# Attributes
#

# @!attribute [rw] exception_class
# Fully-qualified Ruby class name of the exception, when the
# failure was raised as one (e.g. `'Rex::ConnectionError'`).
#
# @return [String]
# @return [nil] when the failure was not raised via an exception.

# @!attribute [rw] message
# Human-readable failure message.
#
# @return [String]
# @return [nil] when no message was captured.

# @!attribute [rw] backtrace
# Captured backtrace (newline-joined) at the point the error was
# recorded.
#
# @return [String]
# @return [nil] when no backtrace was available.

# @!attribute [rw] lifecycle_phase
# One of {LIFECYCLE_PHASES}.
#
# @return [String]

# @!attribute [rw] failure_reason
# Short coded reason classifying the failure (e.g.
# `'connection-refused'`, `'auth-failed'`).
#
# @return [String]
# @return [nil] when the failure has no coded reason.

# @!attribute [rw] occurred_at
# Wall-clock time the error was raised.
#
# @return [DateTime]

# @!attribute [rw] created_at
# When this error row was inserted.
#
# @return [DateTime]

# @!attribute [rw] updated_at
# Last time this error row was updated.
#
# @return [DateTime]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should also be immutable.

Suggested change
# @!attribute [rw] updated_at
# Last time this error row was updated.
#
# @return [DateTime]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this makes no sense for Error and Events. I removed these fields. Thanks!

#
# Validations
#

validates :lifecycle_phase, presence: true, inclusion: { in: LIFECYCLE_PHASES }
validates :occurred_at, presence: true

Metasploit::Concern.run(self)
end
58 changes: 58 additions & 0 deletions app/models/mdm/module_execution_event.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Optional generalized timeline entry attached to a
# {Mdm::ModuleExecution}. Used to record milestones that aren't
# artifacts in their own right (e.g. `'session_opened'`,
# `'target_selected'`, `'check_returned'`).
class Mdm::ModuleExecutionEvent < ApplicationRecord
self.table_name = 'module_execution_events'

#
# Associations
#

# The module execution this event belongs to.
#
# @return [Mdm::ModuleExecution]
belongs_to :module_execution,
class_name: 'Mdm::ModuleExecution',
inverse_of: :events

#
# Attributes
#

# @!attribute [rw] name
# Short identifier for the event, e.g. `'session_opened'`.
#
# @return [String]

# @!attribute [rw] payload
# Free-form structured data describing the event. Schema is
# per-event-name; consumers should treat unknown keys as opaque.
#
# @return [Hash]
# @return [nil] when the event carries no structured payload.

# @!attribute [rw] occurred_at
# Wall-clock time the event was emitted.
#
# @return [DateTime]

# @!attribute [rw] created_at
# When this event row was inserted.
#
# @return [DateTime]

# @!attribute [rw] updated_at
# Last time this event row was updated.
#
# @return [DateTime]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Events should be immutable, so let's remove the updated_at column so once it's created, it stays the same.

Suggested change
# @!attribute [rw] updated_at
# Last time this event row was updated.
#
# @return [DateTime]


#
# Validations
#

validates :name, presence: true
validates :occurred_at, presence: true

Metasploit::Concern.run(self)
end
7 changes: 7 additions & 0 deletions app/models/mdm/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ class Mdm::User < ApplicationRecord
class_name: 'MetasploitDataModels::ModuleRun',
inverse_of: :user

# Module executions started by this user (reporting refactor).
has_many :module_executions,
class_name: 'Mdm::ModuleExecution',
foreign_key: :originating_user_id,
inverse_of: :originating_user,
dependent: :nullify

# Tags created by the user.
has_many :tags,
class_name: 'Mdm::Tag',
Expand Down
6 changes: 6 additions & 0 deletions app/models/mdm/workspace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ class Mdm::Workspace < ApplicationRecord
# Hosts in this workspace.
has_many :hosts, :dependent => :destroy, :class_name => 'Mdm::Host'

# Module executions recorded in this workspace (reporting refactor).
has_many :module_executions,
class_name: 'Mdm::ModuleExecution',
inverse_of: :workspace,
dependent: :destroy

# Listeners running for this workspace.
has_many :listeners, :dependent => :destroy, :class_name => 'Mdm::Listener'

Expand Down
Loading
Loading