Skip to content

Commit 780789c

Browse files
committed
Module metadata: Fix stale module detection and add per-type metadata index
1 parent aee4762 commit 780789c

File tree

3 files changed

+231
-11
lines changed

3 files changed

+231
-11
lines changed

lib/msf/core/module_manager/cache.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ def module_info_by_path_from_database!(allowed_paths=[""])
177177
reference_name = module_metadata.ref_name
178178

179179
# Skip cached modules that are not in our allowed load paths
180-
next if allowed_paths.select{|x| path.index(x) == 0}.empty?
180+
next unless allowed_paths.any? { |x| path.start_with?(x) }
181181

182182
parent_path = get_parent_path(path, type)
183183

lib/msf/core/modules/metadata/cache.rb

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ def refresh_metadata(module_sets)
7878
end
7979
end
8080
end
81+
if has_changes
82+
rebuild_type_cache
83+
end
8184
}
8285
if has_changes
8386
update_store
@@ -89,8 +92,8 @@ def refresh_metadata(module_sets)
8992
def module_metadata(type)
9093
@mutex.synchronize do
9194
wait_for_load
92-
# TODO: Should probably figure out a way to cache this
93-
@module_metadata_cache.filter_map { |_, metadata| [metadata.ref_name, metadata] if metadata.type == type }.to_h
95+
type_hash = @module_metadata_by_type[type]
96+
type_hash ? type_hash.dup : {}
9497
end
9598
end
9699

@@ -129,7 +132,9 @@ def remove_from_cache(module_name)
129132
module_metadata.ref_name.eql? module_name
130133
}
131134

132-
return old_cache_size != @module_metadata_cache.size
135+
removed = old_cache_size != @module_metadata_cache.size
136+
# Type index will be rebuilt in bulk by refresh_metadata after all removals
137+
removed
133138
end
134139

135140
def wait_for_load
@@ -141,29 +146,50 @@ def refresh_metadata_instance_internal(module_instance)
141146

142147
# Remove all instances of modules pointing to the same path. This prevents stale data hanging
143148
# around when modules are incorrectly typed (eg: Auxiliary that should be Exploit)
149+
had_type_mismatch_deletion = false
144150
@module_metadata_cache.delete_if {|_, module_metadata|
145-
module_metadata.path.eql? metadata_obj.path && module_metadata.type != module_metadata.type
151+
is_stale = module_metadata.path.eql?(metadata_obj.path) && module_metadata.type != metadata_obj.type
152+
had_type_mismatch_deletion = true if is_stale
153+
is_stale
146154
}
147155

148-
@module_metadata_cache[get_cache_key(module_instance)] = metadata_obj
156+
cache_key = get_cache_key(module_instance)
157+
@module_metadata_cache[cache_key] = metadata_obj
158+
159+
if had_type_mismatch_deletion
160+
# Type changed - full rebuild needed since we removed entries from other type buckets
161+
rebuild_type_cache
162+
else
163+
# Common case - just update the single entry in the type index
164+
type_hash = (@module_metadata_by_type[metadata_obj.type] ||= {})
165+
type_hash[metadata_obj.ref_name] = metadata_obj
166+
end
149167
end
150168

151169
def get_cache_key(module_instance)
152-
key = ''
153-
key << (module_instance.type.nil? ? '' : module_instance.type)
154-
key << '_'
155-
key << module_instance.class.refname
156-
return key
170+
"#{module_instance.type}_#{module_instance.class.refname}"
171+
end
172+
173+
# Rebuild the per-type index from the main cache.
174+
def rebuild_type_cache
175+
by_type = {}
176+
@module_metadata_cache.each_value do |metadata|
177+
type_hash = (by_type[metadata.type] ||= {})
178+
type_hash[metadata.ref_name] = metadata
179+
end
180+
@module_metadata_by_type = by_type
157181
end
158182

159183
def initialize
160184
super
161185
@mutex = Mutex.new
162186
@module_metadata_cache = {}
187+
@module_metadata_by_type = {}
163188
@store_loaded = false
164189
@console = Rex::Ui::Text::Output::Stdio.new
165190
@load_thread = Thread.new {
166191
init_store
192+
rebuild_type_cache
167193
@store_loaded = true
168194
}
169195
end
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
# frozen_string_literal: true
2+
3+
require 'rspec'
4+
require 'msfenv'
5+
6+
RSpec.describe Msf::Modules::Metadata::Cache do
7+
# Build a testable Cache instance without triggering the Singleton constructor
8+
# (which spawns a thread and loads the store from disk).
9+
let(:cache) do
10+
obj = described_class.send(:allocate)
11+
obj.instance_variable_set(:@mutex, Mutex.new)
12+
obj.instance_variable_set(:@module_metadata_cache, {})
13+
obj.instance_variable_set(:@module_metadata_by_type, {})
14+
obj.instance_variable_set(:@store_loaded, true)
15+
obj.instance_variable_set(:@load_thread, Thread.new {})
16+
obj
17+
end
18+
19+
def make_metadata(type:, ref_name:, path: '/modules/test.rb')
20+
Msf::Modules::Metadata::Obj.from_hash({
21+
'name' => ref_name,
22+
'fullname' => "#{type}/#{ref_name}",
23+
'rank' => 300,
24+
'type' => type,
25+
'author' => ['rspec'],
26+
'description' => 'Test module',
27+
'references' => [],
28+
'mod_time' => '2024-01-01 00:00:00 +0000',
29+
'path' => path,
30+
'is_install_path' => false,
31+
'ref_name' => ref_name
32+
})
33+
end
34+
35+
def populate_cache(cache, *entries)
36+
entries.each do |entry|
37+
cache.instance_variable_get(:@module_metadata_cache)["#{entry.type}_#{entry.ref_name}"] = entry
38+
end
39+
cache.send(:rebuild_type_cache)
40+
end
41+
42+
# Fake module instance for refresh_metadata_instance_internal
43+
def make_module_instance(type:, refname:, path: '/modules/test.rb')
44+
mod = double('module_instance')
45+
klass = double('module_class', refname: refname)
46+
allow(mod).to receive(:type).and_return(type)
47+
allow(mod).to receive(:class).and_return(klass)
48+
allow(mod).to receive(:refname).and_return(refname)
49+
allow(mod).to receive(:realname).and_return("#{type}/#{refname}")
50+
allow(mod).to receive(:name).and_return(refname)
51+
allow(mod).to receive(:aliases).and_return([])
52+
allow(mod).to receive(:disclosure_date).and_return(nil)
53+
allow(mod).to receive(:rank).and_return(300)
54+
allow(mod).to receive(:description).and_return('Test')
55+
allow(mod).to receive(:author).and_return([])
56+
allow(mod).to receive(:references).and_return([])
57+
allow(mod).to receive(:post_auth?).and_return(false)
58+
allow(mod).to receive(:default_cred?).and_return(false)
59+
allow(mod).to receive(:platform_to_s).and_return('')
60+
allow(mod).to receive(:platform).and_return(nil)
61+
allow(mod).to receive(:arch_to_s).and_return('')
62+
allow(mod).to receive(:datastore).and_return({})
63+
allow(mod).to receive(:file_path).and_return(path)
64+
allow(mod).to receive(:respond_to?).and_return(false)
65+
allow(mod).to receive(:has_check?).and_return(false)
66+
allow(mod).to receive(:notes).and_return({})
67+
mod
68+
end
69+
70+
describe '#module_metadata' do
71+
it 'returns modules of the requested type' do
72+
exploit = make_metadata(type: 'exploit', ref_name: 'test/vuln')
73+
auxiliary = make_metadata(type: 'auxiliary', ref_name: 'scanner/test', path: '/modules/aux.rb')
74+
populate_cache(cache, exploit, auxiliary)
75+
76+
result = cache.module_metadata('exploit')
77+
expect(result.keys).to eq(['test/vuln'])
78+
expect(result['test/vuln']).to eq(exploit)
79+
end
80+
81+
it 'returns an empty hash for unknown types' do
82+
exploit = make_metadata(type: 'exploit', ref_name: 'test/vuln')
83+
populate_cache(cache, exploit)
84+
85+
expect(cache.module_metadata('post')).to eq({})
86+
end
87+
88+
it 'returns a copy that does not affect internal state' do
89+
exploit = make_metadata(type: 'exploit', ref_name: 'test/vuln')
90+
populate_cache(cache, exploit)
91+
92+
result = cache.module_metadata('exploit')
93+
result.delete('test/vuln')
94+
95+
expect(cache.module_metadata('exploit').keys).to eq(['test/vuln'])
96+
end
97+
end
98+
99+
describe '#rebuild_type_cache' do
100+
it 'groups all entries by type' do
101+
e1 = make_metadata(type: 'exploit', ref_name: 'test/a', path: '/modules/a.rb')
102+
e2 = make_metadata(type: 'exploit', ref_name: 'test/b', path: '/modules/b.rb')
103+
aux = make_metadata(type: 'auxiliary', ref_name: 'scan/c', path: '/modules/c.rb')
104+
populate_cache(cache, e1, e2, aux)
105+
106+
expect(cache.module_metadata('exploit').size).to eq(2)
107+
expect(cache.module_metadata('auxiliary').size).to eq(1)
108+
end
109+
end
110+
111+
describe '#refresh_metadata_instance_internal' do
112+
it 'adds a new module to the type index' do
113+
mod = make_module_instance(type: 'exploit', refname: 'test/new', path: '/modules/new.rb')
114+
cache.send(:rebuild_type_cache)
115+
cache.send(:refresh_metadata_instance_internal, mod)
116+
117+
result = cache.module_metadata('exploit')
118+
expect(result.keys).to eq(['test/new'])
119+
end
120+
121+
it 'updates an existing module in the type index' do
122+
old = make_metadata(type: 'exploit', ref_name: 'test/mod', path: '/modules/mod.rb')
123+
populate_cache(cache, old)
124+
125+
mod = make_module_instance(type: 'exploit', refname: 'test/mod', path: '/modules/mod.rb')
126+
cache.send(:refresh_metadata_instance_internal, mod)
127+
128+
result = cache.module_metadata('exploit')
129+
expect(result.size).to eq(1)
130+
expect(result['test/mod']).not_to eq(old)
131+
end
132+
133+
context 'when a module changes type' do
134+
it 'removes the old type entry and adds to the new type' do
135+
# Module starts as auxiliary
136+
old_aux = make_metadata(type: 'auxiliary', ref_name: 'test/mistyped', path: '/modules/mistyped.rb')
137+
other_aux = make_metadata(type: 'auxiliary', ref_name: 'scan/other', path: '/modules/other.rb')
138+
populate_cache(cache, old_aux, other_aux)
139+
140+
expect(cache.module_metadata('auxiliary').size).to eq(2)
141+
expect(cache.module_metadata('exploit')).to eq({})
142+
143+
# Now refresh it as an exploit (same path, different type)
144+
mod = make_module_instance(type: 'exploit', refname: 'test/mistyped', path: '/modules/mistyped.rb')
145+
cache.send(:refresh_metadata_instance_internal, mod)
146+
147+
# Old auxiliary entry should be gone, other auxiliary should remain
148+
aux_result = cache.module_metadata('auxiliary')
149+
expect(aux_result.size).to eq(1)
150+
expect(aux_result.keys).to eq(['scan/other'])
151+
152+
# New exploit entry should exist
153+
exploit_result = cache.module_metadata('exploit')
154+
expect(exploit_result.size).to eq(1)
155+
expect(exploit_result.keys).to eq(['test/mistyped'])
156+
end
157+
158+
it 'does not leave stale entries in the main cache' do
159+
old = make_metadata(type: 'auxiliary', ref_name: 'test/stale', path: '/modules/stale.rb')
160+
populate_cache(cache, old)
161+
162+
mod = make_module_instance(type: 'exploit', refname: 'test/stale', path: '/modules/stale.rb')
163+
cache.send(:refresh_metadata_instance_internal, mod)
164+
165+
main_cache = cache.instance_variable_get(:@module_metadata_cache)
166+
types = main_cache.values.map(&:type).uniq
167+
expect(types).to eq(['exploit'])
168+
end
169+
end
170+
end
171+
172+
describe '#remove_from_cache' do
173+
it 'removes the named module and returns true' do
174+
mod = make_metadata(type: 'exploit', ref_name: 'test/remove', path: '/modules/remove.rb')
175+
populate_cache(cache, mod)
176+
177+
result = cache.send(:remove_from_cache, 'test/remove')
178+
expect(result).to be true
179+
expect(cache.instance_variable_get(:@module_metadata_cache)).to be_empty
180+
end
181+
182+
it 'returns false when the module does not exist' do
183+
result = cache.send(:remove_from_cache, 'test/nonexistent')
184+
expect(result).to be false
185+
end
186+
end
187+
188+
describe '#get_cache_key' do
189+
it 'returns type_refname' do
190+
mod = make_module_instance(type: 'exploit', refname: 'test/key')
191+
expect(cache.send(:get_cache_key, mod)).to eq('exploit_test/key')
192+
end
193+
end
194+
end

0 commit comments

Comments
 (0)