From a091880ee18cdf3b55ba0d812a8179277f171b9b Mon Sep 17 00:00:00 2001 From: Christophe De La Fuente Date: Mon, 8 Jun 2026 15:48:21 +0200 Subject: [PATCH 1/4] Add ModuleExecution, ModuleExecutionError and ModuleExecutionEvent data models --- app/models/mdm/module_execution.rb | 219 ++++++++++++++ app/models/mdm/module_execution_error.rb | 88 ++++++ app/models/mdm/module_execution_event.rb | 58 ++++ app/models/mdm/user.rb | 7 + app/models/mdm/workspace.rb | 6 + ...20260608120000_create_module_executions.rb | 85 ++++++ ...08120001_create_module_execution_errors.rb | 28 ++ ...08120002_create_module_execution_events.rb | 25 ++ lib/mdm.rb | 3 + .../models/mdm/module_execution_error_spec.rb | 59 ++++ .../models/mdm/module_execution_event_spec.rb | 44 +++ spec/app/models/mdm/module_execution_spec.rb | 182 ++++++++++++ spec/dummy/db/structure.sql | 275 ++++++++++++++++++ spec/factories/mdm/module_execution_errors.rb | 13 + spec/factories/mdm/module_execution_events.rb | 11 + spec/factories/mdm/module_executions.rb | 16 + 16 files changed, 1119 insertions(+) create mode 100644 app/models/mdm/module_execution.rb create mode 100644 app/models/mdm/module_execution_error.rb create mode 100644 app/models/mdm/module_execution_event.rb create mode 100644 db/migrate/20260608120000_create_module_executions.rb create mode 100644 db/migrate/20260608120001_create_module_execution_errors.rb create mode 100644 db/migrate/20260608120002_create_module_execution_events.rb create mode 100644 spec/app/models/mdm/module_execution_error_spec.rb create mode 100644 spec/app/models/mdm/module_execution_event_spec.rb create mode 100644 spec/app/models/mdm/module_execution_spec.rb create mode 100644 spec/factories/mdm/module_execution_errors.rb create mode 100644 spec/factories/mdm/module_execution_events.rb create mode 100644 spec/factories/mdm/module_executions.rb diff --git a/app/models/mdm/module_execution.rb b/app/models/mdm/module_execution.rb new file mode 100644 index 00000000..5997a2d6 --- /dev/null +++ b/app/models/mdm/module_execution.rb @@ -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 + + # User interface or programmatic surface that initiated this execution. + ORIGINATING_UIS = %w[console rpc json_rpc mcp external import plugin autocheck].freeze + + # 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 + + # + # 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] + 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] + 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] + 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] + # @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 diff --git a/app/models/mdm/module_execution_error.rb b/app/models/mdm/module_execution_error.rb new file mode 100644 index 00000000..7b4a3d5b --- /dev/null +++ b/app/models/mdm/module_execution_error.rb @@ -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] + + # + # Validations + # + + validates :lifecycle_phase, presence: true, inclusion: { in: LIFECYCLE_PHASES } + validates :occurred_at, presence: true + + Metasploit::Concern.run(self) +end diff --git a/app/models/mdm/module_execution_event.rb b/app/models/mdm/module_execution_event.rb new file mode 100644 index 00000000..4f5d56a3 --- /dev/null +++ b/app/models/mdm/module_execution_event.rb @@ -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] + + # + # Validations + # + + validates :name, presence: true + validates :occurred_at, presence: true + + Metasploit::Concern.run(self) +end diff --git a/app/models/mdm/user.rb b/app/models/mdm/user.rb index 7be38fd5..5183bcb9 100755 --- a/app/models/mdm/user.rb +++ b/app/models/mdm/user.rb @@ -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', diff --git a/app/models/mdm/workspace.rb b/app/models/mdm/workspace.rb index af0d43cc..fb3390a3 100644 --- a/app/models/mdm/workspace.rb +++ b/app/models/mdm/workspace.rb @@ -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' diff --git a/db/migrate/20260608120000_create_module_executions.rb b/db/migrate/20260608120000_create_module_executions.rb new file mode 100644 index 00000000..d2d4117c --- /dev/null +++ b/db/migrate/20260608120000_create_module_executions.rb @@ -0,0 +1,85 @@ +# Creates the `module_executions` table. Every module invocation (or +# direct-write pseudo-execution) ultimately writes one row here. +class CreateModuleExecutions < ActiveRecord::Migration[7.0] + def change + create_table :module_executions do |t| + t.references :workspace, + null: false, + foreign_key: true, + index: true + t.text :module_reference_name, null: false + t.text :module_type, null: false + t.text :kind, null: false, default: 'run' + t.jsonb :options_snapshot + t.text :originating_ui, null: false + t.references :originating_user, + foreign_key: { to_table: :users }, + index: true + t.text :originating_token_ref + t.references :parent_execution, + foreign_key: { to_table: :module_executions }, + index: false + t.column :started_at, :timestamptz, null: false + t.column :ended_at, :timestamptz + t.text :terminal_status + t.text :failure_reason + t.text :failure_message + t.integer :single_entity_failure_count, null: false, default: 0 + t.jsonb :last_single_entity_errors + + t.column :created_at, :timestamptz, null: false + t.column :updated_at, :timestamptz, null: false + end + + add_index :module_executions, + [:workspace_id, :started_at], + order: { started_at: :desc }, + name: 'idx_module_executions_on_workspace_and_started_at' + + add_index :module_executions, + [:module_reference_name, :started_at], + order: { started_at: :desc }, + name: 'idx_module_executions_on_reference_name_and_started_at' + + add_index :module_executions, + :parent_execution_id, + where: 'parent_execution_id IS NOT NULL', + name: 'idx_module_executions_on_parent_execution_id_not_null' + + add_index :module_executions, + [:kind, :originating_ui], + name: 'idx_module_executions_on_kind_and_originating_ui' + + # Enumerated values are also validated at the AR layer; the DB + # CHECK constraints provide a fail-loud safety net for non-AR + # writers (raw SQL, future maintenance scripts). + add_check_constraint :module_executions, + "module_type IN ('exploit','auxiliary','post','payload'," \ + "'encoder','evasion','nop','external')", + name: 'module_executions_module_type_check' + + add_check_constraint :module_executions, + "kind IN ('run','check','import','direct_write')", + name: 'module_executions_kind_check' + + add_check_constraint :module_executions, + "originating_ui IN ('console','rpc','json_rpc','mcp'," \ + "'external','import','plugin','autocheck')", + name: 'module_executions_originating_ui_check' + + add_check_constraint :module_executions, + "terminal_status IS NULL OR terminal_status IN " \ + "('running','success','neutral','expected_failure','unhandled_exception')", + name: 'module_executions_terminal_status_check' + + # (ended_at IS NULL) <=> (terminal_status is NULL or 'running') + add_check_constraint :module_executions, + "(ended_at IS NULL AND (terminal_status IS NULL OR terminal_status = 'running')) " \ + "OR (ended_at IS NOT NULL AND terminal_status IS NOT NULL AND terminal_status <> 'running')", + name: 'module_executions_terminal_status_lifecycle_check' + + add_check_constraint :module_executions, + 'ended_at IS NULL OR ended_at >= started_at', + name: 'module_executions_ended_at_after_started_at_check' + end +end diff --git a/db/migrate/20260608120001_create_module_execution_errors.rb b/db/migrate/20260608120001_create_module_execution_errors.rb new file mode 100644 index 00000000..61648949 --- /dev/null +++ b/db/migrate/20260608120001_create_module_execution_errors.rb @@ -0,0 +1,28 @@ +# Creates the `module_execution_errors` table — one row per unhandled exception +# or explicit failure raised within a {Mdm::ModuleExecution} +class CreateModuleExecutionErrors < ActiveRecord::Migration[7.0] + def change + create_table :module_execution_errors do |t| + t.references :module_execution, + null: false, + foreign_key: { on_delete: :cascade }, + index: false + t.text :exception_class + t.text :message + t.text :backtrace + t.text :lifecycle_phase, null: false + t.text :failure_reason + t.column :occurred_at, :timestamptz, null: false + t.column :created_at, :timestamptz, null: false + t.column :updated_at, :timestamptz, null: false + end + + add_index :module_execution_errors, + [:module_execution_id, :occurred_at], + name: 'idx_module_execution_errors_on_execution_and_occurred_at' + + add_check_constraint :module_execution_errors, + "lifecycle_phase IN ('setup','check','exploit','cleanup','post','run')", + name: 'module_execution_errors_lifecycle_phase_check' + end +end diff --git a/db/migrate/20260608120002_create_module_execution_events.rb b/db/migrate/20260608120002_create_module_execution_events.rb new file mode 100644 index 00000000..7484a13c --- /dev/null +++ b/db/migrate/20260608120002_create_module_execution_events.rb @@ -0,0 +1,25 @@ +# Creates the `module_execution_events` table — optional generalized timeline +# entries attached to a {Mdm::ModuleExecution}. +class CreateModuleExecutionEvents < ActiveRecord::Migration[7.0] + def change + create_table :module_execution_events do |t| + t.references :module_execution, + null: false, + foreign_key: { on_delete: :cascade }, + index: false + t.text :name, null: false + t.jsonb :payload + t.column :occurred_at, :timestamptz, null: false + t.column :created_at, :timestamptz, null: false + t.column :updated_at, :timestamptz, null: false + end + + add_index :module_execution_events, + [:module_execution_id, :occurred_at], + name: 'idx_module_execution_events_on_execution_and_occurred_at' + + add_index :module_execution_events, + [:name, :occurred_at], + name: 'idx_module_execution_events_on_name_and_occurred_at' + end +end diff --git a/lib/mdm.rb b/lib/mdm.rb index 8801ac0e..94a98bed 100644 --- a/lib/mdm.rb +++ b/lib/mdm.rb @@ -17,6 +17,9 @@ module Mdm autoload :Macro autoload :ModRef autoload :Module + autoload :ModuleExecution + autoload :ModuleExecutionError + autoload :ModuleExecutionEvent autoload :NexposeConsole autoload :Note autoload :Payload diff --git a/spec/app/models/mdm/module_execution_error_spec.rb b/spec/app/models/mdm/module_execution_error_spec.rb new file mode 100644 index 00000000..a040a53e --- /dev/null +++ b/spec/app/models/mdm/module_execution_error_spec.rb @@ -0,0 +1,59 @@ +RSpec.describe Mdm::ModuleExecutionError, type: :model do + it_should_behave_like 'Metasploit::Concern.run' + + context 'factory' do + it 'builds a valid record' do + record = FactoryBot.build(:mdm_module_execution_error) + expect(record).to be_valid + end + end + + context 'database' do + context 'columns' do + it { is_expected.to have_db_column(:module_execution_id).of_type(:integer).with_options(null: false) } + it { is_expected.to have_db_column(:exception_class).of_type(:text) } + it { is_expected.to have_db_column(:message).of_type(:text) } + it { is_expected.to have_db_column(:backtrace).of_type(:text) } + it { is_expected.to have_db_column(:lifecycle_phase).of_type(:text).with_options(null: false) } + it { is_expected.to have_db_column(:failure_reason).of_type(:text) } + it { is_expected.to have_db_column(:occurred_at).with_options(null: false) } + it { is_expected.to have_db_column(:created_at).with_options(null: false) } + it { is_expected.to have_db_column(:updated_at).with_options(null: false) } + end + + context 'indexes' do + it { is_expected.to have_db_index([:module_execution_id, :occurred_at]) } + end + end + + context 'associations' do + it { is_expected.to belong_to(:module_execution).class_name('Mdm::ModuleExecution') } + end + + context 'validations' do + it { is_expected.to validate_presence_of(:lifecycle_phase) } + it { is_expected.to validate_presence_of(:occurred_at) } + + it 'accepts every documented lifecycle phase' do + Mdm::ModuleExecutionError::LIFECYCLE_PHASES.each do |phase| + record = FactoryBot.build(:mdm_module_execution_error, lifecycle_phase: phase) + expect(record).to be_valid, "expected #{phase.inspect} to be valid" + end + end + + it 'rejects an unknown lifecycle phase' do + record = FactoryBot.build(:mdm_module_execution_error, lifecycle_phase: 'bogus') + expect(record).not_to be_valid + expect(record.errors[:lifecycle_phase]).not_to be_empty + end + end + + context 'cascade delete' do + it 'is destroyed when the parent module_execution is destroyed' do + record = FactoryBot.create(:mdm_module_execution_error) + execution = record.module_execution + expect { execution.destroy } + .to change { Mdm::ModuleExecutionError.where(id: record.id).count }.from(1).to(0) + end + end +end diff --git a/spec/app/models/mdm/module_execution_event_spec.rb b/spec/app/models/mdm/module_execution_event_spec.rb new file mode 100644 index 00000000..fa0b3f41 --- /dev/null +++ b/spec/app/models/mdm/module_execution_event_spec.rb @@ -0,0 +1,44 @@ +RSpec.describe Mdm::ModuleExecutionEvent, type: :model do + it_should_behave_like 'Metasploit::Concern.run' + + context 'factory' do + it 'builds a valid record' do + record = FactoryBot.build(:mdm_module_execution_event) + expect(record).to be_valid + end + end + + context 'database' do + context 'columns' do + it { is_expected.to have_db_column(:module_execution_id).of_type(:integer).with_options(null: false) } + it { is_expected.to have_db_column(:name).of_type(:text).with_options(null: false) } + it { is_expected.to have_db_column(:payload) } + it { is_expected.to have_db_column(:occurred_at).with_options(null: false) } + it { is_expected.to have_db_column(:created_at).with_options(null: false) } + it { is_expected.to have_db_column(:updated_at).with_options(null: false) } + end + + context 'indexes' do + it { is_expected.to have_db_index([:module_execution_id, :occurred_at]) } + it { is_expected.to have_db_index([:name, :occurred_at]) } + end + end + + context 'associations' do + it { is_expected.to belong_to(:module_execution).class_name('Mdm::ModuleExecution') } + end + + context 'validations' do + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:occurred_at) } + end + + context 'cascade delete' do + it 'is destroyed when the parent module_execution is destroyed' do + record = FactoryBot.create(:mdm_module_execution_event) + execution = record.module_execution + expect { execution.destroy } + .to change { Mdm::ModuleExecutionEvent.where(id: record.id).count }.from(1).to(0) + end + end +end diff --git a/spec/app/models/mdm/module_execution_spec.rb b/spec/app/models/mdm/module_execution_spec.rb new file mode 100644 index 00000000..94538576 --- /dev/null +++ b/spec/app/models/mdm/module_execution_spec.rb @@ -0,0 +1,182 @@ +RSpec.describe Mdm::ModuleExecution, type: :model do + it_should_behave_like 'Metasploit::Concern.run' + + context 'factory' do + it 'builds a valid record' do + record = FactoryBot.build(:mdm_module_execution) + expect(record).to be_valid + end + end + + context 'database' do + context 'columns' do + it { is_expected.to have_db_column(:workspace_id).of_type(:integer).with_options(null: false) } + it { is_expected.to have_db_column(:module_reference_name).of_type(:text).with_options(null: false) } + it { is_expected.to have_db_column(:module_type).of_type(:text).with_options(null: false) } + it { is_expected.to have_db_column(:kind).of_type(:text).with_options(null: false, default: 'run') } + it { is_expected.to have_db_column(:options_snapshot) } + it { is_expected.to have_db_column(:originating_ui).of_type(:text).with_options(null: false) } + it { is_expected.to have_db_column(:originating_user_id).of_type(:integer) } + it { is_expected.to have_db_column(:originating_token_ref).of_type(:text) } + it { is_expected.to have_db_column(:parent_execution_id).of_type(:integer) } + it { is_expected.to have_db_column(:started_at).with_options(null: false) } + it { is_expected.to have_db_column(:ended_at) } + it { is_expected.to have_db_column(:terminal_status).of_type(:text) } + it { is_expected.to have_db_column(:failure_reason).of_type(:text) } + it { is_expected.to have_db_column(:failure_message).of_type(:text) } + it { is_expected.to have_db_column(:single_entity_failure_count).of_type(:integer).with_options(null: false, default: 0) } + it { is_expected.to have_db_column(:last_single_entity_errors) } + end + + context 'indexes' do + it { is_expected.to have_db_index([:workspace_id, :started_at]) } + it { is_expected.to have_db_index([:module_reference_name, :started_at]) } + it { is_expected.to have_db_index([:kind, :originating_ui]) } + end + end + + context 'associations' do + it { is_expected.to belong_to(:workspace).class_name('Mdm::Workspace') } + it { is_expected.to belong_to(:originating_user).optional.class_name('Mdm::User') } + it { is_expected.to belong_to(:parent_execution).optional.class_name('Mdm::ModuleExecution') } + it { is_expected.to have_many(:children).class_name('Mdm::ModuleExecution') } + it { is_expected.to have_many(:execution_errors).class_name('Mdm::ModuleExecutionError') } + it { is_expected.to have_many(:events).class_name('Mdm::ModuleExecutionEvent') } + end + + context 'validations' do + it { is_expected.to validate_presence_of(:module_reference_name) } + it { is_expected.to validate_presence_of(:module_type) } + it { is_expected.to validate_presence_of(:kind) } + it { is_expected.to validate_presence_of(:originating_ui) } + it { is_expected.to validate_presence_of(:started_at) } + + it 'accepts every documented module_type' do + Mdm::ModuleExecution::MODULE_TYPES.each do |t| + record = FactoryBot.build(:mdm_module_execution, module_type: t) + expect(record).to be_valid, "expected #{t.inspect} to be valid" + end + end + + it 'rejects an unknown module_type' do + record = FactoryBot.build(:mdm_module_execution, module_type: 'bogus') + expect(record).not_to be_valid + end + + it 'accepts every documented kind' do + Mdm::ModuleExecution::KINDS.each do |k| + record = FactoryBot.build(:mdm_module_execution, kind: k) + expect(record).to be_valid, "expected #{k.inspect} to be valid" + end + end + + it 'rejects an unknown kind' do + record = FactoryBot.build(:mdm_module_execution, kind: 'bogus') + expect(record).not_to be_valid + end + + it 'accepts every documented originating_ui' do + Mdm::ModuleExecution::ORIGINATING_UIS.each do |ui| + record = FactoryBot.build(:mdm_module_execution, originating_ui: ui) + expect(record).to be_valid, "expected #{ui.inspect} to be valid" + end + end + + it 'rejects an unknown originating_ui' do + record = FactoryBot.build(:mdm_module_execution, originating_ui: 'bogus') + expect(record).not_to be_valid + end + + it 'accepts a NULL terminal_status while ended_at is NULL' do + record = FactoryBot.build(:mdm_module_execution, terminal_status: nil, ended_at: nil) + expect(record).to be_valid + end + + it 'accepts terminal_status = running while ended_at is NULL' do + record = FactoryBot.build(:mdm_module_execution, terminal_status: 'running', ended_at: nil) + expect(record).to be_valid + end + + it 'rejects a non-running terminal_status while ended_at is NULL' do + record = FactoryBot.build(:mdm_module_execution, terminal_status: 'success', ended_at: nil) + expect(record).not_to be_valid + expect(record.errors[:terminal_status]).not_to be_empty + end + + it 'rejects a NULL terminal_status when ended_at is set' do + now = Time.now.utc + record = FactoryBot.build(:mdm_module_execution, + terminal_status: nil, + started_at: now, + ended_at: now) + expect(record).not_to be_valid + expect(record.errors[:terminal_status]).not_to be_empty + end + + it 'rejects terminal_status = running when ended_at is set' do + now = Time.now.utc + record = FactoryBot.build(:mdm_module_execution, + terminal_status: 'running', + started_at: now, + ended_at: now) + expect(record).not_to be_valid + end + + it 'accepts a terminal status when ended_at is set' do + now = Time.now.utc + record = FactoryBot.build(:mdm_module_execution, + terminal_status: 'success', + started_at: now, + ended_at: now + 1) + expect(record).to be_valid + end + + it 'rejects ended_at before started_at' do + now = Time.now.utc + record = FactoryBot.build(:mdm_module_execution, + terminal_status: 'success', + started_at: now, + ended_at: now - 1) + expect(record).not_to be_valid + expect(record.errors[:ended_at]).not_to be_empty + end + + it 'rejects a negative single_entity_failure_count' do + record = FactoryBot.build(:mdm_module_execution, single_entity_failure_count: -1) + expect(record).not_to be_valid + end + end + + context 'parent / child executions' do + it 'links a child to its parent' do + parent = FactoryBot.create(:mdm_module_execution) + child = FactoryBot.create(:mdm_module_execution, + workspace: parent.workspace, + parent_execution: parent) + expect(parent.children.reload).to include(child) + expect(child.parent_execution).to eq(parent) + end + + it 'nullifies parent_execution_id on children when parent is destroyed' do + parent = FactoryBot.create(:mdm_module_execution) + child = FactoryBot.create(:mdm_module_execution, + workspace: parent.workspace, + parent_execution: parent) + parent.destroy + expect(child.reload.parent_execution_id).to be_nil + end + end + + context 'reverse associations' do + it 'is reachable from its workspace' do + record = FactoryBot.create(:mdm_module_execution) + expect(record.workspace.module_executions).to include(record) + end + + it 'is reachable from its originating user' do + user = FactoryBot.create(:mdm_user) + record = FactoryBot.create(:mdm_module_execution, originating_user: user) + expect(user.module_executions).to include(record) + end + end +end diff --git a/spec/dummy/db/structure.sql b/spec/dummy/db/structure.sql index bfde563e..fb1e505f 100644 --- a/spec/dummy/db/structure.sql +++ b/spec/dummy/db/structure.sql @@ -832,6 +832,130 @@ CREATE SEQUENCE public.module_details_id_seq ALTER SEQUENCE public.module_details_id_seq OWNED BY public.module_details.id; +-- +-- Name: module_execution_errors; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.module_execution_errors ( + id bigint NOT NULL, + module_execution_id bigint NOT NULL, + exception_class text, + message text, + backtrace text, + lifecycle_phase text NOT NULL, + failure_reason text, + occurred_at timestamp with time zone NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + CONSTRAINT module_execution_errors_lifecycle_phase_check CHECK ((lifecycle_phase = ANY (ARRAY['setup'::text, 'check'::text, 'exploit'::text, 'cleanup'::text, 'post'::text, 'run'::text]))) +); + + +-- +-- Name: module_execution_errors_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.module_execution_errors_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: module_execution_errors_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.module_execution_errors_id_seq OWNED BY public.module_execution_errors.id; + + +-- +-- Name: module_execution_events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.module_execution_events ( + id bigint NOT NULL, + module_execution_id bigint NOT NULL, + name text NOT NULL, + payload jsonb, + occurred_at timestamp with time zone NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + + +-- +-- Name: module_execution_events_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.module_execution_events_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: module_execution_events_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.module_execution_events_id_seq OWNED BY public.module_execution_events.id; + + +-- +-- Name: module_executions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.module_executions ( + id bigint NOT NULL, + workspace_id bigint NOT NULL, + module_reference_name text NOT NULL, + module_type text NOT NULL, + kind text DEFAULT 'run'::text NOT NULL, + options_snapshot jsonb, + originating_ui text NOT NULL, + originating_user_id bigint, + originating_token_ref text, + parent_execution_id bigint, + started_at timestamp with time zone NOT NULL, + ended_at timestamp with time zone, + terminal_status text, + failure_reason text, + failure_message text, + single_entity_failure_count integer DEFAULT 0 NOT NULL, + last_single_entity_errors jsonb, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + CONSTRAINT module_executions_ended_at_after_started_at_check CHECK (((ended_at IS NULL) OR (ended_at >= started_at))), + CONSTRAINT module_executions_kind_check CHECK ((kind = ANY (ARRAY['run'::text, 'check'::text, 'import'::text, 'direct_write'::text]))), + CONSTRAINT module_executions_module_type_check CHECK ((module_type = ANY (ARRAY['exploit'::text, 'auxiliary'::text, 'post'::text, 'payload'::text, 'encoder'::text, 'evasion'::text, 'nop'::text, 'external'::text]))), + CONSTRAINT module_executions_originating_ui_check CHECK ((originating_ui = ANY (ARRAY['console'::text, 'rpc'::text, 'json_rpc'::text, 'mcp'::text, 'external'::text, 'import'::text, 'plugin'::text]))), + CONSTRAINT module_executions_terminal_status_check CHECK (((terminal_status IS NULL) OR (terminal_status = ANY (ARRAY['running'::text, 'success'::text, 'neutral'::text, 'expected_failure'::text, 'unhandled_exception'::text])))), + CONSTRAINT module_executions_terminal_status_lifecycle_check CHECK ((((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_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.module_executions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: module_executions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.module_executions_id_seq OWNED BY public.module_executions.id; + + -- -- Name: module_mixins; Type: TABLE; Schema: public; Owner: - -- @@ -2345,6 +2469,27 @@ ALTER TABLE ONLY public.module_authors ALTER COLUMN id SET DEFAULT nextval('publ ALTER TABLE ONLY public.module_details ALTER COLUMN id SET DEFAULT nextval('public.module_details_id_seq'::regclass); +-- +-- Name: module_execution_errors id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.module_execution_errors ALTER COLUMN id SET DEFAULT nextval('public.module_execution_errors_id_seq'::regclass); + + +-- +-- Name: module_execution_events id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.module_execution_events ALTER COLUMN id SET DEFAULT nextval('public.module_execution_events_id_seq'::regclass); + + +-- +-- Name: module_executions id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.module_executions ALTER COLUMN id SET DEFAULT nextval('public.module_executions_id_seq'::regclass); + + -- -- Name: module_mixins id; Type: DEFAULT; Schema: public; Owner: - -- @@ -2781,6 +2926,30 @@ ALTER TABLE ONLY public.module_details ADD CONSTRAINT module_details_pkey PRIMARY KEY (id); +-- +-- Name: module_execution_errors module_execution_errors_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.module_execution_errors + ADD CONSTRAINT module_execution_errors_pkey PRIMARY KEY (id); + + +-- +-- Name: module_execution_events module_execution_events_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.module_execution_events + ADD CONSTRAINT module_execution_events_pkey PRIMARY KEY (id); + + +-- +-- Name: module_executions module_executions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.module_executions + ADD CONSTRAINT module_executions_pkey PRIMARY KEY (id); + + -- -- Name: module_mixins module_mixins_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -3077,6 +3246,55 @@ ALTER TABLE ONLY public.workspaces ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id); +-- +-- Name: idx_module_execution_errors_on_execution_and_occurred_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_module_execution_errors_on_execution_and_occurred_at ON public.module_execution_errors USING btree (module_execution_id, occurred_at); + + +-- +-- Name: idx_module_execution_events_on_execution_and_occurred_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_module_execution_events_on_execution_and_occurred_at ON public.module_execution_events USING btree (module_execution_id, occurred_at); + + +-- +-- Name: idx_module_execution_events_on_name_and_occurred_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_module_execution_events_on_name_and_occurred_at ON public.module_execution_events USING btree (name, occurred_at); + + +-- +-- Name: idx_module_executions_on_kind_and_originating_ui; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_module_executions_on_kind_and_originating_ui ON public.module_executions USING btree (kind, originating_ui); + + +-- +-- Name: idx_module_executions_on_parent_execution_id_not_null; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_module_executions_on_parent_execution_id_not_null ON public.module_executions USING btree (parent_execution_id) WHERE (parent_execution_id IS NOT NULL); + + +-- +-- Name: idx_module_executions_on_reference_name_and_started_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_module_executions_on_reference_name_and_started_at ON public.module_executions USING btree (module_reference_name, started_at DESC); + + +-- +-- Name: idx_module_executions_on_workspace_and_started_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_module_executions_on_workspace_and_started_at ON public.module_executions USING btree (workspace_id, started_at DESC); + + -- -- Name: index_automatic_exploitation_match_results_on_match_id; Type: INDEX; Schema: public; Owner: - -- @@ -3238,6 +3456,20 @@ CREATE INDEX index_module_details_on_name ON public.module_details USING btree ( CREATE INDEX index_module_details_on_refname ON public.module_details USING btree (refname); +-- +-- Name: index_module_executions_on_originating_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_module_executions_on_originating_user_id ON public.module_executions USING btree (originating_user_id); + + +-- +-- Name: index_module_executions_on_workspace_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_module_executions_on_workspace_id ON public.module_executions USING btree (workspace_id); + + -- -- Name: index_module_mixins_on_detail_id; Type: INDEX; Schema: public; Owner: - -- @@ -3463,6 +3695,30 @@ ALTER TABLE ONLY public.service_links ADD CONSTRAINT fk_rails_20de66c25c FOREIGN KEY (child_id) REFERENCES public.services(id); +-- +-- Name: module_executions fk_rails_2570bf9d24; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.module_executions + ADD CONSTRAINT fk_rails_2570bf9d24 FOREIGN KEY (workspace_id) REFERENCES public.workspaces(id); + + +-- +-- Name: module_executions fk_rails_48fa7ee725; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.module_executions + ADD CONSTRAINT fk_rails_48fa7ee725 FOREIGN KEY (originating_user_id) REFERENCES public.users(id); + + +-- +-- Name: module_execution_errors fk_rails_4bedaea82a; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.module_execution_errors + ADD CONSTRAINT fk_rails_4bedaea82a FOREIGN KEY (module_execution_id) REFERENCES public.module_executions(id) ON DELETE CASCADE; + + -- -- Name: service_links fk_rails_656cd59e76; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -3471,6 +3727,22 @@ ALTER TABLE ONLY public.service_links ADD CONSTRAINT fk_rails_656cd59e76 FOREIGN KEY (parent_id) REFERENCES public.services(id); +-- +-- Name: module_execution_events fk_rails_95f78fad9a; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.module_execution_events + ADD CONSTRAINT fk_rails_95f78fad9a FOREIGN KEY (module_execution_id) REFERENCES public.module_executions(id) ON DELETE CASCADE; + + +-- +-- Name: module_executions fk_rails_d2125611b4; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.module_executions + ADD CONSTRAINT fk_rails_d2125611b4 FOREIGN KEY (parent_execution_id) REFERENCES public.module_executions(id); + + -- -- PostgreSQL database dump complete -- @@ -3605,6 +3877,9 @@ INSERT INTO "schema_migrations" (version) VALUES ('20251231162000'), ('20260130124052'), ('20260411000000'), +('20260608120000'), +('20260608120001'), +('20260608120002'), ('21'), ('22'), ('23'), diff --git a/spec/factories/mdm/module_execution_errors.rb b/spec/factories/mdm/module_execution_errors.rb new file mode 100644 index 00000000..abb1ad36 --- /dev/null +++ b/spec/factories/mdm/module_execution_errors.rb @@ -0,0 +1,13 @@ +FactoryBot.define do + factory :mdm_module_execution_error, + aliases: [:module_execution_error], + class: 'Mdm::ModuleExecutionError' do + association :module_execution, factory: :mdm_module_execution + + lifecycle_phase { 'run' } + occurred_at { Time.now.utc } + exception_class { 'RuntimeError' } + message { 'something went wrong' } + failure_reason { nil } + end +end diff --git a/spec/factories/mdm/module_execution_events.rb b/spec/factories/mdm/module_execution_events.rb new file mode 100644 index 00000000..24c247da --- /dev/null +++ b/spec/factories/mdm/module_execution_events.rb @@ -0,0 +1,11 @@ +FactoryBot.define do + factory :mdm_module_execution_event, + aliases: [:module_execution_event], + class: 'Mdm::ModuleExecutionEvent' do + association :module_execution, factory: :mdm_module_execution + + sequence(:name) { |n| "milestone_#{n}" } + payload { {} } + occurred_at { Time.now.utc } + end +end diff --git a/spec/factories/mdm/module_executions.rb b/spec/factories/mdm/module_executions.rb new file mode 100644 index 00000000..9493aa36 --- /dev/null +++ b/spec/factories/mdm/module_executions.rb @@ -0,0 +1,16 @@ +FactoryBot.define do + factory :mdm_module_execution, + aliases: [:module_execution], + class: 'Mdm::ModuleExecution' do + association :workspace, factory: :mdm_workspace + + sequence(:module_reference_name) { |n| "auxiliary/scanner/example_#{n}" } + module_type { 'auxiliary' } + kind { 'run' } + originating_ui { 'console' } + started_at { Time.now.utc } + terminal_status { 'running' } + ended_at { nil } + single_entity_failure_count { 0 } + end +end From 7bafa640b845bfe2b3adf39351fd27986ba65385 Mon Sep 17 00:00:00 2001 From: Christophe De La Fuente Date: Tue, 16 Jun 2026 19:20:21 +0200 Subject: [PATCH 2/4] Temporary fix for the YARD dependency issue. --- .github/workflows/verify.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 39d9bb41..5716ec8d 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -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 From f9fc9fb7f9084a29e89cfe83a833197a9671aa0d Mon Sep 17 00:00:00 2001 From: Christophe De La Fuente Date: Fri, 10 Jul 2026 13:51:36 +0200 Subject: [PATCH 3/4] Code review changes --- app/models/mdm/module_execution.rb | 10 +++++----- app/models/mdm/module_execution_error.rb | 5 ----- app/models/mdm/module_execution_event.rb | 5 ----- .../20260608120000_create_module_executions.rb | 10 +++++----- ...608120001_create_module_execution_errors.rb | 1 - ...608120002_create_module_execution_events.rb | 1 - .../models/mdm/module_execution_error_spec.rb | 1 - .../models/mdm/module_execution_event_spec.rb | 1 - spec/app/models/mdm/module_execution_spec.rb | 18 +++++++++--------- spec/dummy/db/structure.sql | 12 +++++------- spec/factories/mdm/module_executions.rb | 2 +- 11 files changed, 25 insertions(+), 41 deletions(-) diff --git a/app/models/mdm/module_execution.rb b/app/models/mdm/module_execution.rb index 5997a2d6..262aad34 100644 --- a/app/models/mdm/module_execution.rb +++ b/app/models/mdm/module_execution.rb @@ -15,10 +15,10 @@ class Mdm::ModuleExecution < ApplicationRecord 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 + MODULE_TYPES = %w[exploit auxiliary post payload encoder evasion nop].freeze # User interface or programmatic surface that initiated this execution. - ORIGINATING_UIS = %w[console rpc json_rpc mcp external import plugin autocheck].freeze + ORIGINATING_INTERFACES = %w[console rpc json_rpc mcp external import plugin autocheck].freeze # Terminal lifecycle states. `running` is the only non-terminal value # and is permitted while {#ended_at} is `NULL`. @@ -109,8 +109,8 @@ class Mdm::ModuleExecution < ApplicationRecord # @return [Hash] # @return [nil] when no options were captured. - # @!attribute [rw] originating_ui - # One of {ORIGINATING_UIS}. + # @!attribute [rw] originating_interface + # One of {ORIGINATING_INTERFACES}. # # @return [String] @@ -187,7 +187,7 @@ class Mdm::ModuleExecution < ApplicationRecord 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 :originating_interface, presence: true, inclusion: { in: ORIGINATING_INTERFACES } validates :terminal_status, inclusion: { in: TERMINAL_STATUSES, allow_nil: true } validates :started_at, presence: true validates :single_entity_failure_count, diff --git a/app/models/mdm/module_execution_error.rb b/app/models/mdm/module_execution_error.rb index 7b4a3d5b..a96abe3d 100644 --- a/app/models/mdm/module_execution_error.rb +++ b/app/models/mdm/module_execution_error.rb @@ -72,11 +72,6 @@ class Mdm::ModuleExecutionError < ApplicationRecord # # @return [DateTime] - # @!attribute [rw] updated_at - # Last time this error row was updated. - # - # @return [DateTime] - # # Validations # diff --git a/app/models/mdm/module_execution_event.rb b/app/models/mdm/module_execution_event.rb index 4f5d56a3..d9dbbdae 100644 --- a/app/models/mdm/module_execution_event.rb +++ b/app/models/mdm/module_execution_event.rb @@ -42,11 +42,6 @@ class Mdm::ModuleExecutionEvent < ApplicationRecord # # @return [DateTime] - # @!attribute [rw] updated_at - # Last time this event row was updated. - # - # @return [DateTime] - # # Validations # diff --git a/db/migrate/20260608120000_create_module_executions.rb b/db/migrate/20260608120000_create_module_executions.rb index d2d4117c..641d8e6a 100644 --- a/db/migrate/20260608120000_create_module_executions.rb +++ b/db/migrate/20260608120000_create_module_executions.rb @@ -11,7 +11,7 @@ def change t.text :module_type, null: false t.text :kind, null: false, default: 'run' t.jsonb :options_snapshot - t.text :originating_ui, null: false + t.text :originating_interface, null: false t.references :originating_user, foreign_key: { to_table: :users }, index: true @@ -47,8 +47,8 @@ def change name: 'idx_module_executions_on_parent_execution_id_not_null' add_index :module_executions, - [:kind, :originating_ui], - name: 'idx_module_executions_on_kind_and_originating_ui' + [:kind, :originating_interface], + name: 'idx_module_executions_on_kind_and_originating_interface' # Enumerated values are also validated at the AR layer; the DB # CHECK constraints provide a fail-loud safety net for non-AR @@ -63,9 +63,9 @@ def change name: 'module_executions_kind_check' add_check_constraint :module_executions, - "originating_ui IN ('console','rpc','json_rpc','mcp'," \ + "originating_interface IN ('console','rpc','json_rpc','mcp'," \ "'external','import','plugin','autocheck')", - name: 'module_executions_originating_ui_check' + name: 'module_executions_originating_interface_check' add_check_constraint :module_executions, "terminal_status IS NULL OR terminal_status IN " \ diff --git a/db/migrate/20260608120001_create_module_execution_errors.rb b/db/migrate/20260608120001_create_module_execution_errors.rb index 61648949..07b3952b 100644 --- a/db/migrate/20260608120001_create_module_execution_errors.rb +++ b/db/migrate/20260608120001_create_module_execution_errors.rb @@ -14,7 +14,6 @@ def change t.text :failure_reason t.column :occurred_at, :timestamptz, null: false t.column :created_at, :timestamptz, null: false - t.column :updated_at, :timestamptz, null: false end add_index :module_execution_errors, diff --git a/db/migrate/20260608120002_create_module_execution_events.rb b/db/migrate/20260608120002_create_module_execution_events.rb index 7484a13c..d1623b87 100644 --- a/db/migrate/20260608120002_create_module_execution_events.rb +++ b/db/migrate/20260608120002_create_module_execution_events.rb @@ -11,7 +11,6 @@ def change t.jsonb :payload t.column :occurred_at, :timestamptz, null: false t.column :created_at, :timestamptz, null: false - t.column :updated_at, :timestamptz, null: false end add_index :module_execution_events, diff --git a/spec/app/models/mdm/module_execution_error_spec.rb b/spec/app/models/mdm/module_execution_error_spec.rb index a040a53e..32fd884e 100644 --- a/spec/app/models/mdm/module_execution_error_spec.rb +++ b/spec/app/models/mdm/module_execution_error_spec.rb @@ -18,7 +18,6 @@ it { is_expected.to have_db_column(:failure_reason).of_type(:text) } it { is_expected.to have_db_column(:occurred_at).with_options(null: false) } it { is_expected.to have_db_column(:created_at).with_options(null: false) } - it { is_expected.to have_db_column(:updated_at).with_options(null: false) } end context 'indexes' do diff --git a/spec/app/models/mdm/module_execution_event_spec.rb b/spec/app/models/mdm/module_execution_event_spec.rb index fa0b3f41..e1b15281 100644 --- a/spec/app/models/mdm/module_execution_event_spec.rb +++ b/spec/app/models/mdm/module_execution_event_spec.rb @@ -15,7 +15,6 @@ it { is_expected.to have_db_column(:payload) } it { is_expected.to have_db_column(:occurred_at).with_options(null: false) } it { is_expected.to have_db_column(:created_at).with_options(null: false) } - it { is_expected.to have_db_column(:updated_at).with_options(null: false) } end context 'indexes' do diff --git a/spec/app/models/mdm/module_execution_spec.rb b/spec/app/models/mdm/module_execution_spec.rb index 94538576..af4c8f1a 100644 --- a/spec/app/models/mdm/module_execution_spec.rb +++ b/spec/app/models/mdm/module_execution_spec.rb @@ -15,7 +15,7 @@ it { is_expected.to have_db_column(:module_type).of_type(:text).with_options(null: false) } it { is_expected.to have_db_column(:kind).of_type(:text).with_options(null: false, default: 'run') } it { is_expected.to have_db_column(:options_snapshot) } - it { is_expected.to have_db_column(:originating_ui).of_type(:text).with_options(null: false) } + it { is_expected.to have_db_column(:originating_interface).of_type(:text).with_options(null: false) } it { is_expected.to have_db_column(:originating_user_id).of_type(:integer) } it { is_expected.to have_db_column(:originating_token_ref).of_type(:text) } it { is_expected.to have_db_column(:parent_execution_id).of_type(:integer) } @@ -31,7 +31,7 @@ context 'indexes' do it { is_expected.to have_db_index([:workspace_id, :started_at]) } it { is_expected.to have_db_index([:module_reference_name, :started_at]) } - it { is_expected.to have_db_index([:kind, :originating_ui]) } + it { is_expected.to have_db_index([:kind, :originating_interface]) } end end @@ -48,7 +48,7 @@ it { is_expected.to validate_presence_of(:module_reference_name) } it { is_expected.to validate_presence_of(:module_type) } it { is_expected.to validate_presence_of(:kind) } - it { is_expected.to validate_presence_of(:originating_ui) } + it { is_expected.to validate_presence_of(:originating_interface) } it { is_expected.to validate_presence_of(:started_at) } it 'accepts every documented module_type' do @@ -75,15 +75,15 @@ expect(record).not_to be_valid end - it 'accepts every documented originating_ui' do - Mdm::ModuleExecution::ORIGINATING_UIS.each do |ui| - record = FactoryBot.build(:mdm_module_execution, originating_ui: ui) - expect(record).to be_valid, "expected #{ui.inspect} to be valid" + it 'accepts every documented originating_interface' do + Mdm::ModuleExecution::ORIGINATING_INTERFACES.each do |iface| + record = FactoryBot.build(:mdm_module_execution, originating_interface: iface) + expect(record).to be_valid, "expected #{iface.inspect} to be valid" end end - it 'rejects an unknown originating_ui' do - record = FactoryBot.build(:mdm_module_execution, originating_ui: 'bogus') + it 'rejects an unknown originating_interface' do + record = FactoryBot.build(:mdm_module_execution, originating_interface: 'bogus') expect(record).not_to be_valid end diff --git a/spec/dummy/db/structure.sql b/spec/dummy/db/structure.sql index fb1e505f..3bb08d22 100644 --- a/spec/dummy/db/structure.sql +++ b/spec/dummy/db/structure.sql @@ -846,7 +846,6 @@ CREATE TABLE public.module_execution_errors ( failure_reason text, occurred_at timestamp with time zone NOT NULL, created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL, CONSTRAINT module_execution_errors_lifecycle_phase_check CHECK ((lifecycle_phase = ANY (ARRAY['setup'::text, 'check'::text, 'exploit'::text, 'cleanup'::text, 'post'::text, 'run'::text]))) ); @@ -880,8 +879,7 @@ CREATE TABLE public.module_execution_events ( name text NOT NULL, payload jsonb, occurred_at timestamp with time zone NOT NULL, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL + created_at timestamp with time zone NOT NULL ); @@ -915,7 +913,7 @@ CREATE TABLE public.module_executions ( module_type text NOT NULL, kind text DEFAULT 'run'::text NOT NULL, options_snapshot jsonb, - originating_ui text NOT NULL, + originating_interface text NOT NULL, originating_user_id bigint, originating_token_ref text, parent_execution_id bigint, @@ -931,7 +929,7 @@ CREATE TABLE public.module_executions ( CONSTRAINT module_executions_ended_at_after_started_at_check CHECK (((ended_at IS NULL) OR (ended_at >= started_at))), CONSTRAINT module_executions_kind_check CHECK ((kind = ANY (ARRAY['run'::text, 'check'::text, 'import'::text, 'direct_write'::text]))), CONSTRAINT module_executions_module_type_check CHECK ((module_type = ANY (ARRAY['exploit'::text, 'auxiliary'::text, 'post'::text, 'payload'::text, 'encoder'::text, 'evasion'::text, 'nop'::text, 'external'::text]))), - CONSTRAINT module_executions_originating_ui_check CHECK ((originating_ui = ANY (ARRAY['console'::text, 'rpc'::text, 'json_rpc'::text, 'mcp'::text, 'external'::text, 'import'::text, 'plugin'::text]))), + CONSTRAINT module_executions_originating_interface_check CHECK ((originating_interface = ANY (ARRAY['console'::text, 'rpc'::text, 'json_rpc'::text, 'mcp'::text, 'external'::text, 'import'::text, 'plugin'::text, 'autocheck'::text]))), CONSTRAINT module_executions_terminal_status_check CHECK (((terminal_status IS NULL) OR (terminal_status = ANY (ARRAY['running'::text, 'success'::text, 'neutral'::text, 'expected_failure'::text, 'unhandled_exception'::text])))), CONSTRAINT module_executions_terminal_status_lifecycle_check CHECK ((((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)))) ); @@ -3268,10 +3266,10 @@ CREATE INDEX idx_module_execution_events_on_name_and_occurred_at ON public.modul -- --- Name: idx_module_executions_on_kind_and_originating_ui; Type: INDEX; Schema: public; Owner: - +-- Name: idx_module_executions_on_kind_and_originating_interface; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_module_executions_on_kind_and_originating_ui ON public.module_executions USING btree (kind, originating_ui); +CREATE INDEX idx_module_executions_on_kind_and_originating_interface ON public.module_executions USING btree (kind, originating_interface); -- diff --git a/spec/factories/mdm/module_executions.rb b/spec/factories/mdm/module_executions.rb index 9493aa36..bcf32da1 100644 --- a/spec/factories/mdm/module_executions.rb +++ b/spec/factories/mdm/module_executions.rb @@ -7,7 +7,7 @@ sequence(:module_reference_name) { |n| "auxiliary/scanner/example_#{n}" } module_type { 'auxiliary' } kind { 'run' } - originating_ui { 'console' } + originating_interface { 'console' } started_at { Time.now.utc } terminal_status { 'running' } ended_at { nil } From f0acdb12346b0493ac48b2178c47721e97fa86a1 Mon Sep 17 00:00:00 2001 From: Christophe De La Fuente Date: Thu, 16 Jul 2026 13:18:11 +0200 Subject: [PATCH 4/4] Add `check_code` and `check_message` to ModuleExecution data model --- app/models/mdm/module_execution.rb | 32 ++++++++++++++ ...20260608120000_create_module_executions.rb | 17 +++++++ spec/app/models/mdm/module_execution_spec.rb | 44 +++++++++++++++++++ spec/dummy/db/structure.sql | 4 ++ 4 files changed, 97 insertions(+) diff --git a/app/models/mdm/module_execution.rb b/app/models/mdm/module_execution.rb index 262aad34..0f9b8f84 100644 --- a/app/models/mdm/module_execution.rb +++ b/app/models/mdm/module_execution.rb @@ -24,6 +24,10 @@ class Mdm::ModuleExecution < ApplicationRecord # and is permitted while {#ended_at} is `NULL`. TERMINAL_STATUSES = %w[running success neutral expected_failure unhandled_exception].freeze + # Six-value enum published by `Msf::Exploit::CheckCode` for + # {#check_code}. Only populated when {#kind} is `'check'`. + CHECK_CODES = %w[vulnerable appears detected safe unknown unsupported].freeze + # # Associations # @@ -155,6 +159,24 @@ class Mdm::ModuleExecution < ApplicationRecord # @return [String] # @return [nil] when the execution did not fail. + # @!attribute [rw] check_code + # The {CHECK_CODES} value returned by a `kind = 'check'` execution + # (i.e. `Msf::Exploit::CheckCode#code`). NULL for non-check + # executions, and NULL for check executions that raised before + # returning a `CheckCode`. + # + # @return [String] + # @return [nil] when not populated. + + # @!attribute [rw] check_message + # Human-readable message that accompanies {#check_code} (i.e. + # `Msf::Exploit::CheckCode#message`). NULL when {#check_code} is + # NULL, and may be NULL even when {#check_code} is set if the + # check returned no message. + # + # @return [String] + # @return [nil] when not populated. + # @!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 @@ -189,12 +211,14 @@ class Mdm::ModuleExecution < ApplicationRecord validates :kind, presence: true, inclusion: { in: KINDS } validates :originating_interface, presence: true, inclusion: { in: ORIGINATING_INTERFACES } validates :terminal_status, inclusion: { in: TERMINAL_STATUSES, allow_nil: true } + validates :check_code, inclusion: { in: CHECK_CODES, 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 + validate :check_code_only_on_check_kind Metasploit::Concern.run(self) @@ -216,4 +240,12 @@ def terminal_status_consistent_with_ended_at errors.add(:terminal_status, 'must be a terminal value once ended_at is set') end end + + def check_code_only_on_check_kind + return if kind == 'check' + return if check_code.nil? && check_message.nil? + + errors.add(:check_code, 'may only be populated when kind is check') unless check_code.nil? + errors.add(:check_message, 'may only be populated when kind is check') unless check_message.nil? + end end diff --git a/db/migrate/20260608120000_create_module_executions.rb b/db/migrate/20260608120000_create_module_executions.rb index 641d8e6a..42aee1fa 100644 --- a/db/migrate/20260608120000_create_module_executions.rb +++ b/db/migrate/20260608120000_create_module_executions.rb @@ -24,6 +24,8 @@ def change t.text :terminal_status t.text :failure_reason t.text :failure_message + t.text :check_code + t.text :check_message t.integer :single_entity_failure_count, null: false, default: 0 t.jsonb :last_single_entity_errors @@ -81,5 +83,20 @@ def change add_check_constraint :module_executions, 'ended_at IS NULL OR ended_at >= started_at', name: 'module_executions_ended_at_after_started_at_check' + + # `check_code` / `check_message` are only meaningful for + # `kind = 'check'` executions. `check` executions may still leave + # them NULL (e.g. when `check` raises before returning a + # `CheckCode`), but non-`check` executions must never populate them. + add_check_constraint :module_executions, + "kind = 'check' OR (check_code IS NULL AND check_message IS NULL)", + name: 'module_executions_check_code_only_on_check_kind' + + # Constrain `check_code` to the six-value enum published by + # `Msf::Exploit::CheckCode`. `NULL` remains permitted. + add_check_constraint :module_executions, + "check_code IS NULL OR check_code IN " \ + "('vulnerable','appears','detected','safe','unknown','unsupported')", + name: 'module_executions_check_code_check' end end diff --git a/spec/app/models/mdm/module_execution_spec.rb b/spec/app/models/mdm/module_execution_spec.rb index af4c8f1a..05fa7936 100644 --- a/spec/app/models/mdm/module_execution_spec.rb +++ b/spec/app/models/mdm/module_execution_spec.rb @@ -24,6 +24,8 @@ it { is_expected.to have_db_column(:terminal_status).of_type(:text) } it { is_expected.to have_db_column(:failure_reason).of_type(:text) } it { is_expected.to have_db_column(:failure_message).of_type(:text) } + it { is_expected.to have_db_column(:check_code).of_type(:text) } + it { is_expected.to have_db_column(:check_message).of_type(:text) } it { is_expected.to have_db_column(:single_entity_failure_count).of_type(:integer).with_options(null: false, default: 0) } it { is_expected.to have_db_column(:last_single_entity_errors) } end @@ -145,6 +147,48 @@ record = FactoryBot.build(:mdm_module_execution, single_entity_failure_count: -1) expect(record).not_to be_valid end + + context 'check_code / check_message' do + it 'accepts every documented CHECK_CODES value on a kind=check execution' do + Mdm::ModuleExecution::CHECK_CODES.each do |code| + record = FactoryBot.build( + :mdm_module_execution, + kind: 'check', + check_code: code, + check_message: "check returned #{code}" + ) + expect(record).to be_valid, "expected check_code #{code.inspect} to be valid" + end + end + + it 'rejects an unknown check_code' do + record = FactoryBot.build(:mdm_module_execution, kind: 'check', check_code: 'bogus') + expect(record).not_to be_valid + expect(record.errors[:check_code]).not_to be_empty + end + + it 'accepts a NULL check_code on a kind=check execution' do + record = FactoryBot.build(:mdm_module_execution, kind: 'check', check_code: nil) + expect(record).to be_valid + end + + it 'rejects check_code on a non-check execution' do + record = FactoryBot.build(:mdm_module_execution, kind: 'run', check_code: 'safe') + expect(record).not_to be_valid + expect(record.errors[:check_code]).not_to be_empty + end + + it 'rejects check_message on a non-check execution' do + record = FactoryBot.build( + :mdm_module_execution, + kind: 'run', + check_code: nil, + check_message: 'orphan message' + ) + expect(record).not_to be_valid + expect(record.errors[:check_message]).not_to be_empty + end + end end context 'parent / child executions' do diff --git a/spec/dummy/db/structure.sql b/spec/dummy/db/structure.sql index 3bb08d22..8f07058c 100644 --- a/spec/dummy/db/structure.sql +++ b/spec/dummy/db/structure.sql @@ -922,10 +922,14 @@ CREATE TABLE public.module_executions ( terminal_status text, failure_reason text, failure_message text, + check_code text, + check_message text, single_entity_failure_count integer DEFAULT 0 NOT NULL, last_single_entity_errors jsonb, created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, + CONSTRAINT module_executions_check_code_check CHECK (((check_code IS NULL) OR (check_code = ANY (ARRAY['vulnerable'::text, 'appears'::text, 'detected'::text, 'safe'::text, 'unknown'::text, 'unsupported'::text])))), + CONSTRAINT module_executions_check_code_only_on_check_kind CHECK (((kind = 'check'::text) OR ((check_code IS NULL) AND (check_message IS NULL)))), CONSTRAINT module_executions_ended_at_after_started_at_check CHECK (((ended_at IS NULL) OR (ended_at >= started_at))), CONSTRAINT module_executions_kind_check CHECK ((kind = ANY (ARRAY['run'::text, 'check'::text, 'import'::text, 'direct_write'::text]))), CONSTRAINT module_executions_module_type_check CHECK ((module_type = ANY (ARRAY['exploit'::text, 'auxiliary'::text, 'post'::text, 'payload'::text, 'encoder'::text, 'evasion'::text, 'nop'::text, 'external'::text]))),