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 diff --git a/app/models/mdm/module_execution.rb b/app/models/mdm/module_execution.rb new file mode 100644 index 00000000..c3082850 --- /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_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`. + 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_interface + # One of {ORIGINATING_INTERFACES}. + # + # @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_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, + 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..a96abe3d --- /dev/null +++ b/app/models/mdm/module_execution_error.rb @@ -0,0 +1,83 @@ +# 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] + + # + # 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..d9dbbdae --- /dev/null +++ b/app/models/mdm/module_execution_event.rb @@ -0,0 +1,53 @@ +# 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] + + # + # 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..641d8e6a --- /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_interface, 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_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 + # 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_interface IN ('console','rpc','json_rpc','mcp'," \ + "'external','import','plugin','autocheck')", + name: 'module_executions_originating_interface_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..07b3952b --- /dev/null +++ b/db/migrate/20260608120001_create_module_execution_errors.rb @@ -0,0 +1,27 @@ +# 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 + 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..d1623b87 --- /dev/null +++ b/db/migrate/20260608120002_create_module_execution_events.rb @@ -0,0 +1,24 @@ +# 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 + 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..32fd884e --- /dev/null +++ b/spec/app/models/mdm/module_execution_error_spec.rb @@ -0,0 +1,58 @@ +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) } + 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..e1b15281 --- /dev/null +++ b/spec/app/models/mdm/module_execution_event_spec.rb @@ -0,0 +1,43 @@ +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) } + 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..af4c8f1a --- /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_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) } + 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_interface]) } + 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_interface) } + 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_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_interface' do + record = FactoryBot.build(:mdm_module_execution, originating_interface: '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..3bb08d22 100644 --- a/spec/dummy/db/structure.sql +++ b/spec/dummy/db/structure.sql @@ -832,6 +832,128 @@ 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, + 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 +); + + +-- +-- 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_interface 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_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)))) +); + + +-- +-- 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 +2467,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 +2924,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 +3244,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_interface; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_module_executions_on_kind_and_originating_interface ON public.module_executions USING btree (kind, originating_interface); + + +-- +-- 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 +3454,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 +3693,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 +3725,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 +3875,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..bcf32da1 --- /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_interface { 'console' } + started_at { Time.now.utc } + terminal_status { 'running' } + ended_at { nil } + single_entity_failure_count { 0 } + end +end