Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
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
2 changes: 0 additions & 2 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,3 @@ jobs:
bundle exec rake --version
bundle exec rake db:create db:migrate
bundle exec rake spec
bundle exec rake spec
bundle exec rake yard
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
Loading
Loading