diff --git a/app/models/mdm/service.rb b/app/models/mdm/service.rb index 9bd8c630..e0d98561 100755 --- a/app/models/mdm/service.rb +++ b/app/models/mdm/service.rb @@ -96,6 +96,28 @@ class Mdm::Service < ApplicationRecord dependent: :destroy, inverse_of: :service + # @!attribute [rw] parent_links + # Links to parent services of this service. + # + # @return [Array] + has_many :parent_links, + class_name: 'Mdm::ServiceLink', + foreign_key: 'child_id', + dependent: :destroy, + inverse_of: :child + + # @!attribute [rw] parent_links + # Link to child services of this service. + # + # @return [Array] + has_many :child_links, + class_name: 'Mdm::ServiceLink', + foreign_key: 'parent_id', + dependent: :destroy, + inverse_of: :parent + + + # # through: :task_services # @@ -128,6 +150,27 @@ class Mdm::Service < ApplicationRecord # @return [Array] has_many :web_vulns, :through => :web_sites, :class_name => 'Mdm::WebVuln' + # + # through: :parent_links + # + + # @!attribute [rw] parents + # Parent services of this service. + # + # @return [Array] + has_many :parents, through: :parent_links, source: :parent + + # + # through: :child_links + # + + # @!attribute [rw] children + # Child services of this service. + # + # @return [Array] + has_many :children, through: :child_links, source: :child + + # # Attributes # @@ -157,6 +200,11 @@ class Mdm::Service < ApplicationRecord # # @return [String] element of {STATES}. + # @!attribute [rw] resource + # Additional resource information about the service, such as a URL or path. + # + # @return [JSONB] + # # Callbacks # @@ -227,7 +275,9 @@ class Mdm::Service < ApplicationRecord message: 'already exists on this host and protocol', scope: [ :host_id, - :proto + :proto, + :name, + :resource ] } validates :proto, @@ -263,5 +313,14 @@ def normalize_host_os end end + # Destroy this service if it does not have parents {#service_links} + # + # @return [void] + def destroy_if_orphaned + self.class.transaction do + destroy if parents.count == 0 + end + end + Metasploit::Concern.run(self) end diff --git a/app/models/mdm/service_link.rb b/app/models/mdm/service_link.rb new file mode 100644 index 00000000..53b55240 --- /dev/null +++ b/app/models/mdm/service_link.rb @@ -0,0 +1,52 @@ +# Join model between {Mdm::Service} and {Mdm::Service} for many-to-many self-referencing relationship +class Mdm::ServiceLink < ApplicationRecord + self.table_name = 'service_links' + + # + # Associations + # + + # Parent service + belongs_to :parent, + class_name: 'Mdm::Service', + inverse_of: :child_links + + # Child service + belongs_to :child, + class_name: 'Mdm::Service', + inverse_of: :parent_links + + # Destroy orphaned child when destroying a service link + after_destroy :destroy_orphan_child + + # + # Attributes + # + + # @!attribute created_at + # When this task service was created. + # + # @return [DateTime] + + # @!attribute updated_at + # The last time this task service was updated. + # + # @return [DateTime] + + # + # Validations + # + + validates :parent_id, + :uniqueness => { + :scope => :child_id + } + + def destroy_orphan_child + Mdm::Service.where(id: child.id).first&.destroy_if_orphaned + end + private :destroy_orphan_child + + Metasploit::Concern.run(self) +end + diff --git a/app/models/mdm/vuln.rb b/app/models/mdm/vuln.rb index 6c6ddafd..7373c9e7 100755 --- a/app/models/mdm/vuln.rb +++ b/app/models/mdm/vuln.rb @@ -160,6 +160,12 @@ class Mdm::Vuln < ApplicationRecord # # @return [Integer] + # @!attribute [rw] resource + # The resource that this vulnerability is associated with. + # It is stored as a JSONB object. + # + # @return [JSONB] + # # Callbacks # diff --git a/db/migrate/20250716155919_add_resource_to_mdm_vuln.rb b/db/migrate/20250716155919_add_resource_to_mdm_vuln.rb new file mode 100644 index 00000000..c9ec023b --- /dev/null +++ b/db/migrate/20250716155919_add_resource_to_mdm_vuln.rb @@ -0,0 +1,5 @@ +class AddResourceToMdmVuln < ActiveRecord::Migration[7.0] + def change + add_column :vulns, :resource, :jsonb, null: false, default: {} + end +end diff --git a/db/migrate/20250717170556_add_resource_to_services.rb b/db/migrate/20250717170556_add_resource_to_services.rb new file mode 100644 index 00000000..d9f47410 --- /dev/null +++ b/db/migrate/20250717170556_add_resource_to_services.rb @@ -0,0 +1,5 @@ +class AddResourceToServices < ActiveRecord::Migration[7.0] + def change + add_column :services, :resource, :jsonb, null: false, default: {} + end +end diff --git a/db/migrate/20250718122714_create_service_links.rb b/db/migrate/20250718122714_create_service_links.rb new file mode 100644 index 00000000..00c203cc --- /dev/null +++ b/db/migrate/20250718122714_create_service_links.rb @@ -0,0 +1,11 @@ +class CreateServiceLinks < ActiveRecord::Migration[7.0] + def change + create_table :service_links do |t| + t.references :parent, null: false, foreign_key: { to_table: :services } + t.references :child, null: false, foreign_key: { to_table: :services } + t.timestamps + end + add_index :service_links, [:parent_id, :child_id], unique: true + end +end + diff --git a/db/migrate/20250720082201_drop_service_uniqueness_index2.rb b/db/migrate/20250720082201_drop_service_uniqueness_index2.rb new file mode 100644 index 00000000..753278ab --- /dev/null +++ b/db/migrate/20250720082201_drop_service_uniqueness_index2.rb @@ -0,0 +1,5 @@ +class DropServiceUniquenessIndex2 < ActiveRecord::Migration[7.0] + def change + remove_index(:services, :host_id_and_port_and_proto) + end +end diff --git a/db/migrate/20250721114306_remove_duplicate_services3.rb b/db/migrate/20250721114306_remove_duplicate_services3.rb new file mode 100644 index 00000000..6c604384 --- /dev/null +++ b/db/migrate/20250721114306_remove_duplicate_services3.rb @@ -0,0 +1,17 @@ +class RemoveDuplicateServices3 < ActiveRecord::Migration[7.0] + def change + select_mgr = Mdm::Service.arel_table.project( + Mdm::Service[:host_id], + Mdm::Service[:proto], + Mdm::Service[:port].count + ).group( + 'host_id', + 'port', + 'proto' + ).having(Mdm::Service[:port].count.gt(1)) + + Mdm::Service.find_by_sql(select_mgr).each(&:destroy) + + add_index :services, [:host_id, :port, :proto, :name, :resource], unique: true, name: 'index_services_on_5_columns' + end +end diff --git a/lib/mdm.rb b/lib/mdm.rb index 713f82dc..8801ac0e 100644 --- a/lib/mdm.rb +++ b/lib/mdm.rb @@ -44,6 +44,7 @@ module Mdm autoload :WmapRequest autoload :WmapTarget autoload :Workspace + autoload :ServiceLink # Causes the model_name for all Mdm modules to not include the Mdm:: prefix in their name. # diff --git a/spec/app/models/mdm/service_link_spec.rb b/spec/app/models/mdm/service_link_spec.rb new file mode 100644 index 00000000..647387a0 --- /dev/null +++ b/spec/app/models/mdm/service_link_spec.rb @@ -0,0 +1,112 @@ +RSpec.describe Mdm::ServiceLink, type: :model do + it_should_behave_like 'Metasploit::Concern.run' + + context 'factory' do + it 'should be valid' do + service_link = FactoryBot.build(:mdm_service_link) + expect(service_link).to be_valid + end + end + + context 'database' do + + context 'timestamps'do + it { is_expected.to have_db_column(:created_at).of_type(:datetime).with_options(:null => false) } + it { is_expected.to have_db_column(:updated_at).of_type(:datetime).with_options(:null => false) } + end + + context 'columns' do + it { is_expected.to have_db_column(:parent_id).of_type(:integer).with_options(:null => false) } + it { is_expected.to have_db_column(:child_id).of_type(:integer).with_options(:null => false) } + end + end + + context '#destroy' do + it 'should successfully destroy one Mdm::ServiceLink' do + service_link = FactoryBot.create(:mdm_service_link) + expect { service_link.destroy }.to_not raise_error + expect { service_link.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + context 'with one parent and one child' do + let(:parent_service1) { FactoryBot.create(:mdm_service, name: 'parent_service1') } + let(:child_service1) { FactoryBot.create(:mdm_service, name: 'child_service1') } + let!(:service_link1) { FactoryBot.create(:mdm_service_link, parent: parent_service1, child: child_service1) } + + it 'should only destroy the child service' do + expect { service_link1.destroy }.to_not raise_error + expect { service_link1.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { child_service1.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { parent_service1.reload }.to_not raise_error(ActiveRecord::RecordNotFound) + end + + context 'with multiple children' do + let(:child_service2) { FactoryBot.create(:mdm_service, name: 'child_service2') } + let!(:service_link2) { FactoryBot.create(:mdm_service_link, parent: parent_service1, child: child_service2) } + + it 'should only destroy the child service related to this service link' do + expect { service_link1.destroy }.to_not raise_error + expect { service_link1.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { service_link2.reload }.to_not raise_error(ActiveRecord::RecordNotFound) + expect { child_service1.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { child_service2.reload }.to_not raise_error(ActiveRecord::RecordNotFound) + expect { parent_service1.reload }.to_not raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'with multiple nested children' do + let(:child_service2) { FactoryBot.create(:mdm_service, name: 'child_service2') } + let!(:service_link2) { FactoryBot.create(:mdm_service_link, parent: child_service1, child: child_service2) } + + it 'should only destroy the nested child services and service links' do + expect { service_link1.destroy }.to_not raise_error + expect { service_link1.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { service_link2.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { child_service1.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { child_service2.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { parent_service1.reload }.to_not raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'with a child that has another parent' do + let(:parent_service2) { FactoryBot.create(:mdm_service, name: 'parent_service2') } + let!(:service_link2) { FactoryBot.create(:mdm_service_link, parent: parent_service2, child: child_service1) } + + it 'should not destroy the child' do + expect { service_link1.destroy }.to_not raise_error + expect { service_link1.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { service_link2.reload }.to_not raise_error(ActiveRecord::RecordNotFound) + expect { child_service1.reload }.to_not raise_error(ActiveRecord::RecordNotFound) + expect { parent_service1.reload }.to_not raise_error(ActiveRecord::RecordNotFound) + expect { parent_service2.reload }.to_not raise_error(ActiveRecord::RecordNotFound) + end + end + end + end + + context "Associations" do + it { is_expected.to belong_to(:parent).class_name('Mdm::Service') } + it { is_expected.to belong_to(:child).class_name('Mdm::Service') } + end + + context "validations" do + it "should not allow duplicate associations" do + parent_service = FactoryBot.build(:mdm_service) + child_service = FactoryBot.build(:mdm_service) + FactoryBot.create(:mdm_service_link, :parent => parent_service, :child => child_service) + service_link2 = FactoryBot.build(:mdm_service_link, :parent => parent_service, :child => child_service) + expect(service_link2).not_to be_valid + end + end + + context 'callbacks' do + context 'before_destroy' do + it 'should call #destroy_orphan_child' do + service_link = FactoryBot.create(:mdm_service_link) + expect(service_link).to receive(:destroy_orphan_child) + service_link.destroy + end + end + end + +end diff --git a/spec/app/models/mdm/service_spec.rb b/spec/app/models/mdm/service_spec.rb index d580369e..dc965ae4 100644 --- a/spec/app/models/mdm/service_spec.rb +++ b/spec/app/models/mdm/service_spec.rb @@ -35,6 +35,10 @@ it { is_expected.to have_many(:web_pages).class_name('Mdm::WebPage').through(:web_sites) } it { is_expected.to have_many(:web_forms).class_name('Mdm::WebForm').through(:web_sites) } it { is_expected.to have_many(:web_vulns).class_name('Mdm::WebVuln').through(:web_sites) } + it { is_expected.to have_many(:parent_links).class_name('Mdm::ServiceLink').dependent(:destroy) } + it { is_expected.to have_many(:parents).class_name('Mdm::Service').through(:parent_links) } + it { is_expected.to have_many(:child_links).class_name('Mdm::ServiceLink').dependent(:destroy) } + it { is_expected.to have_many(:children).class_name('Mdm::Service').through(:child_links) } it { is_expected.to belong_to(:host).class_name('Mdm::Host') } end @@ -114,14 +118,70 @@ end context '#destroy' do - it 'should successfully destroy the object' do - service = FactoryBot.create(:mdm_service) - expect { - service.destroy - }.to_not raise_error - expect { - service.reload - }.to raise_error(ActiveRecord::RecordNotFound) + let(:service) { FactoryBot.create(:mdm_service) } + + it 'should successfully destroy one Mdm::Service' do + expect { service.destroy }.to_not raise_error + expect { service.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + context 'with one parent and one child' do + let(:parent_service1) { FactoryBot.create(:mdm_service, name: 'parent_service1') } + let(:child_service1) { FactoryBot.create(:mdm_service, name: 'child_service1') } + + before :example do + service.parents << parent_service1 + service.children << child_service1 + end + + it 'should only destroy the child service' do + expect { service.destroy }.to_not raise_error + expect { service.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { child_service1.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { parent_service1.reload }.to_not raise_error(ActiveRecord::RecordNotFound) + end + + context 'with multiple children' do + let(:child_service2) { FactoryBot.create(:mdm_service, name: 'child_service2') } + + it 'should all the child services' do + service.children << child_service2 + + expect { service.destroy }.to_not raise_error + expect { service.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { child_service1.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { child_service2.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { parent_service1.reload }.to_not raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'with multiple nested children' do + let(:child_service2) { FactoryBot.create(:mdm_service, name: 'child_service2') } + + it 'should all the nested child services' do + child_service1.children << child_service2 + + expect { service.destroy }.to_not raise_error + expect { service.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { child_service1.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { child_service2.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { parent_service1.reload }.to_not raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'with a child that has another parent' do + let(:parent_service2) { FactoryBot.create(:mdm_service, name: 'parent_service2') } + + it 'should not destroy the child' do + child_service1.parents << parent_service2 + + expect { service.destroy }.to_not raise_error + expect { service.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { child_service1.reload }.to_not raise_error(ActiveRecord::RecordNotFound) + expect { parent_service1.reload }.to_not raise_error(ActiveRecord::RecordNotFound) + expect { parent_service2.reload }.to_not raise_error(ActiveRecord::RecordNotFound) + end + end end end @@ -222,7 +282,7 @@ context 'when a duplicate service already exists' do let(:service1) { FactoryBot.create(:mdm_service)} - let(:service2) { FactoryBot.build(:mdm_service, :host => service1.host, :port => service1.port, :proto => service1.proto )} + let(:service2) { FactoryBot.build(:mdm_service, :host => service1.host, :port => service1.port, :proto => service1.proto, :resource => service1.resource, :name => service1.name) } it 'is not valid' do expect(service2).to_not be_valid end diff --git a/spec/app/models/mdm/vuln_spec.rb b/spec/app/models/mdm/vuln_spec.rb index 69b3b169..140a9d09 100644 --- a/spec/app/models/mdm/vuln_spec.rb +++ b/spec/app/models/mdm/vuln_spec.rb @@ -133,6 +133,7 @@ it { is_expected.to have_db_column(:origin_id).of_type(:integer) } it { is_expected.to have_db_column(:origin_type).of_type(:string) } it { is_expected.to have_db_column(:service_id).of_type(:integer) } + it { is_expected.to have_db_column(:resource).of_type(:jsonb) } context 'counter caches' do it { is_expected.to have_db_column(:vuln_attempt_count).of_type(:integer).with_options(:default => 0) } diff --git a/spec/factories/mdm/service_links.rb b/spec/factories/mdm/service_links.rb new file mode 100644 index 00000000..2477064b --- /dev/null +++ b/spec/factories/mdm/service_links.rb @@ -0,0 +1,8 @@ +# Read about factories at https://github.com/thoughtbot/factory_bot + +FactoryBot.define do + factory :mdm_service_link, :class => 'Mdm::ServiceLink' do + association :parent, :factory => :mdm_service + association :child, :factory => :mdm_service + end +end diff --git a/spec/factories/mdm/services.rb b/spec/factories/mdm/services.rb index 05cc69c2..3ae48d0d 100644 --- a/spec/factories/mdm/services.rb +++ b/spec/factories/mdm/services.rb @@ -12,6 +12,7 @@ port { generate :port } proto { generate :mdm_service_proto } state { 'open' } + resource { generate :mdm_service_resource } factory :web_service do proto { 'tcp' } @@ -23,6 +24,10 @@ "mdm_service_name#{n}" } + sequence(:mdm_service_resource) { |n| + { "mdm_service_resource#{n}".to_sym => "mdm_service_resource_value#{n}" } + } + sequence :mdm_service_proto, Mdm::Service::PROTOS.cycle port_bits = 16