Commit d4ef47a2 authored by Olivier Gonzalez's avatar Olivier Gonzalez Committed by Kamil Trzciński

Use POROs for security report vulnerabilities

Generate POROS instead of hashes when parsing security reports
parent adfb33b8
......@@ -38,7 +38,7 @@ module Security
def create_vulnerability(occurrence)
vulnerability = create_or_find_vulnerability_object(occurrence)
occurrence[:identifiers].map do |identifier|
occurrence.identifiers.map do |identifier|
create_vulnerability_identifier_object(vulnerability, identifier)
end
......@@ -48,14 +48,13 @@ module Security
# rubocop: disable CodeReuse/ActiveRecord
def create_or_find_vulnerability_object(occurrence)
find_params = {
scanner: scanners_objects[occurrence[:scanner]],
primary_identifier: identifiers_objects[occurrence[:primary_identifier]],
location_fingerprint: occurrence[:location_fingerprint]
scanner: scanners_objects[occurrence.scanner.key],
primary_identifier: identifiers_objects[occurrence.primary_identifier.key],
location_fingerprint: occurrence.location_fingerprint
}
create_params = occurrence.except(
:scanner, :primary_identifier,
:location_fingerprint, :identifiers)
create_params = occurrence.to_hash
.except(:compare_key, :identifiers, :scanner) # rubocop: disable CodeReuse/ActiveRecord
begin
project.vulnerabilities
......@@ -69,7 +68,7 @@ module Security
def create_vulnerability_identifier_object(vulnerability, identifier)
vulnerability.occurrence_identifiers.find_or_create_by!( # rubocop: disable CodeReuse/ActiveRecord
identifier: identifiers_objects[identifier])
identifier: identifiers_objects[identifier.key])
rescue ActiveRecord::RecordNotUnique
end
......@@ -81,13 +80,13 @@ module Security
def scanners_objects
strong_memoize(:scanners_objects) do
@report.scanners.map do |key, scanner|
[key, existing_scanner_objects[key] || project.vulnerability_scanners.build(scanner)]
[key, existing_scanner_objects[key] || project.vulnerability_scanners.build(scanner.to_hash)]
end.to_h
end
end
def all_scanners_external_ids
@report.scanners.values.map { |scanner| scanner[:external_id] }
@report.scanners.values.map(&:external_id)
end
def existing_scanner_objects
......@@ -101,13 +100,13 @@ module Security
def identifiers_objects
strong_memoize(:identifiers_objects) do
@report.identifiers.map do |key, identifier|
[key, existing_identifiers_objects[key] || project.vulnerability_identifiers.build(identifier)]
[key, existing_identifiers_objects[key] || project.vulnerability_identifiers.build(identifier.to_hash)]
end.to_h
end
end
def all_identifiers_fingerprints
@report.identifiers.values.map { |identifier| identifier[:fingerprint] }
@report.identifiers.values.map(&:fingerprint)
end
def existing_identifiers_objects
......
......@@ -44,29 +44,28 @@ module Gitlab
def create_vulnerability(report, data, version)
scanner = create_scanner(report, data['scanner'] || mutate_scanner_tool(data['tool']))
identifiers = create_identifiers(report, data['identifiers'])
report.add_occurrence(
uuid: SecureRandom.uuid,
report_type: report.type,
name: data['message'],
primary_identifier: identifiers.first,
project_fingerprint: generate_project_fingerprint(data['cve']),
location_fingerprint: generate_location_fingerprint(data['location']),
severity: parse_level(data['severity']),
confidence: parse_level(data['confidence']),
scanner: scanner,
identifiers: identifiers,
raw_metadata: data.to_json,
metadata_version: version
)
::Gitlab::Ci::Reports::Security::Occurrence.new(
uuid: SecureRandom.uuid,
report_type: report.type,
name: data['message'],
compare_key: data['cve'],
location_fingerprint: generate_location_fingerprint(data['location']),
severity: parse_level(data['severity']),
confidence: parse_level(data['confidence']),
scanner: scanner,
identifiers: identifiers,
raw_metadata: data.to_json,
metadata_version: version))
end
def create_scanner(report, scanner)
return unless scanner.is_a?(Hash)
report.add_scanner(
external_id: scanner['id'],
name: scanner['name'])
::Gitlab::Ci::Reports::Security::Scanner.new(
external_id: scanner['id'],
name: scanner['name']))
end
def create_identifiers(report, identifiers)
......@@ -81,13 +80,14 @@ module Gitlab
return unless identifier.is_a?(Hash)
report.add_identifier(
external_type: identifier['type'],
external_id: identifier['value'],
name: identifier['name'],
fingerprint: generate_identifier_fingerprint(identifier),
url: identifier['url'])
::Gitlab::Ci::Reports::Security::Identifier.new(
external_type: identifier['type'],
external_id: identifier['value'],
name: identifier['name'],
url: identifier['url']))
end
# TODO: this can be removed as of `12.0`
def mutate_scanner_tool(tool)
{ 'id' => tool, 'name' => tool.capitalize } if tool
end
......@@ -96,14 +96,6 @@ module Gitlab
input.blank? ? 'undefined' : input.downcase
end
def generate_project_fingerprint(compare_key)
Digest::SHA1.hexdigest(compare_key)
end
def generate_identifier_fingerprint(identifier)
Digest::SHA1.hexdigest("#{identifier['type']}:#{identifier['value']}")
end
def generate_location_fingerprint(location)
raise NotImplementedError
end
......
# frozen_string_literal: true
module Gitlab
module Ci
module Reports
module Security
class Identifier
attr_reader :external_id
attr_reader :external_type
attr_reader :fingerprint
attr_reader :name
attr_reader :url
def initialize(external_id:, external_type:, name:, url: nil)
@external_id = external_id
@external_type = external_type
@name = name
@url = url
@fingerprint = generate_fingerprint
end
def key
fingerprint
end
def to_hash
%i[
external_id
external_type
fingerprint
name
url
].each_with_object({}) do |key, hash|
hash[key] = public_send(key) # rubocop:disable GitlabSecurity/PublicSend
end
end
def ==(other)
other.external_type == external_type &&
other.external_id == external_id
end
private
def generate_fingerprint
Digest::SHA1.hexdigest("#{external_type}:#{external_id}")
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Ci
module Reports
module Security
class Occurrence
attr_reader :compare_key
attr_reader :confidence
attr_reader :identifiers
attr_reader :location_fingerprint
attr_reader :metadata_version
attr_reader :name
attr_reader :project_fingerprint
attr_reader :raw_metadata
attr_reader :report_type
attr_reader :scanner
attr_reader :severity
attr_reader :uuid
def initialize(compare_key:, identifiers:, location_fingerprint:, metadata_version:, name:, raw_metadata:, report_type:, scanner:, uuid:, confidence: nil, severity: nil) # rubocop:disable Metrics/ParameterLists
@compare_key = compare_key
@confidence = confidence
@identifiers = identifiers
@location_fingerprint = location_fingerprint
@metadata_version = metadata_version
@name = name
@raw_metadata = raw_metadata
@report_type = report_type
@scanner = scanner
@severity = severity
@uuid = uuid
@project_fingerprint = generate_project_fingerprint
end
def to_hash
%i[
compare_key
confidence
identifiers
location_fingerprint
metadata_version
name
project_fingerprint
raw_metadata
report_type
scanner
severity
uuid
].each_with_object({}) do |key, hash|
hash[key] = public_send(key) # rubocop:disable GitlabSecurity/PublicSend
end
end
def primary_identifier
identifiers.first
end
def ==(other)
other.report_type == report_type &&
other.location_fingerprint == location_fingerprint &&
other.primary_identifier == primary_identifier
end
private
def generate_project_fingerprint
Digest::SHA1.hexdigest(compare_key)
end
end
end
end
end
end
......@@ -22,32 +22,16 @@ module Gitlab
error.present?
end
def add_scanner(params)
scanner_key(params).tap do |key|
scanners[key] ||= params
end
def add_scanner(scanner)
scanners[scanner.key] ||= scanner
end
def add_identifier(params)
identifier_key(params).tap do |key|
identifiers[key] ||= params
end
def add_identifier(identifier)
identifiers[identifier.key] ||= identifier
end
def add_occurrence(params)
params.tap do |occurrence|
occurrences << occurrence
end
end
private
def scanner_key(params)
params.fetch(:external_id)
end
def identifier_key(params)
params.fetch(:fingerprint)
def add_occurrence(occurrence)
occurrences << occurrence
end
end
end
......
# frozen_string_literal: true
module Gitlab
module Ci
module Reports
module Security
class Scanner
attr_accessor :external_id, :name
def initialize(external_id:, name:)
@external_id = external_id
@name = name
end
def key
external_id
end
def to_hash
%i[
external_id
name
].each_with_object({}) do |key, hash|
hash[key] = public_send(key) # rubocop:disable GitlabSecurity/PublicSend
end
end
def ==(other)
other.external_id == external_id
end
end
end
end
end
end
# frozen_string_literal: true
FactoryBot.define do
factory :ci_reports_security_identifier, class: ::Gitlab::Ci::Reports::Security::Identifier do
external_id 'PREDICTABLE_RANDOM'
external_type 'find_sec_bugs_type'
name { "#{external_type}-#{external_id}" }
skip_create
initialize_with do
::Gitlab::Ci::Reports::Security::Identifier.new(attributes)
end
end
end
# frozen_string_literal: true
FactoryBot.define do
factory :ci_reports_security_occurrence, class: ::Gitlab::Ci::Reports::Security::Occurrence do
compare_key 'this_is_supposed_to_be_a_unique_value'
confidence :medium
identifiers { Array.new(1) { FactoryBot.build(:ci_reports_security_identifier) } }
location_fingerprint '4e5b6966dd100170b4b1ad599c7058cce91b57b4'
metadata_version 'sast:1.0'
name 'Cipher with no integrity'
report_type :sast
raw_metadata do
{
description: "The cipher does not provide data integrity update 1",
solution: "GCM mode introduces an HMAC into the resulting encrypted data, providing integrity of the result.",
location: {
file: "maven/src/main/java/com/gitlab/security_products/tests/App.java",
start_line: 29,
end_line: 29,
class: "com.gitlab.security_products.tests.App",
method: "insecureCypher"
},
links: [
{
name: "Cipher does not check for integrity first?",
url: "https://crypto.stackexchange.com/questions/31428/pbewithmd5anddes-cipher-does-not-check-for-integrity-first"
}
]
}.to_json
end
scanner factory: :ci_reports_security_scanner
severity :high
sequence(:uuid) { generate(:vulnerability_occurrence_uuid) }
skip_create
initialize_with do
::Gitlab::Ci::Reports::Security::Occurrence.new(attributes)
end
end
end
# frozen_string_literal: true
FactoryBot.define do
factory :ci_reports_security_scanner, class: ::Gitlab::Ci::Reports::Security::Scanner do
external_id 'find_sec_bugs'
name 'Find Security Bugs'
skip_create
initialize_with do
::Gitlab::Ci::Reports::Security::Scanner.new(attributes)
end
end
end
......@@ -34,15 +34,15 @@ describe Gitlab::Ci::Parsers::Security::ContainerScanning do
it "generates expected location fingerprint" do
expected = Digest::SHA1.hexdigest('debian:9:glibc')
expect(report.occurrences.first[:location_fingerprint]).to eq(expected)
expect(report.occurrences.first.location_fingerprint).to eq(expected)
end
it "generates expected metadata_version" do
expect(report.occurrences.first[:metadata_version]).to eq('1.3')
expect(report.occurrences.first.metadata_version).to eq('1.3')
end
it "adds report image's name to raw_metadata" do
expect(JSON.parse(report.occurrences.first[:raw_metadata]).dig('location', 'image'))
expect(JSON.parse(report.occurrences.first.raw_metadata).dig('location', 'image'))
.to eq('registry.gitlab.com/groulot/container-scanning-test/master:5f21de6956aee99ddb68ae49498662d9872f50ff')
end
end
......
......@@ -27,8 +27,8 @@ describe Gitlab::Ci::Parsers::Security::Dast do
expected1 = Digest::SHA1.hexdigest(':GET:X-Content-Type-Options')
expected2 = Digest::SHA1.hexdigest('/:GET:X-Content-Type-Options')
expect(report.occurrences.first[:location_fingerprint]).to eq(expected1)
expect(report.occurrences.last[:location_fingerprint]).to eq(expected2)
expect(report.occurrences.first.location_fingerprint).to eq(expected1)
expect(report.occurrences.last.location_fingerprint).to eq(expected2)
end
describe 'occurrence properties' do
......@@ -44,7 +44,7 @@ describe Gitlab::Ci::Parsers::Security::Dast do
it 'saves properly occurrence' do
occurrence = report.occurrences.last
expect(occurrence[attribute]).to eq(value)
expect(occurrence.public_send(attribute)).to eq(value)
end
end
end
......
......@@ -34,11 +34,11 @@ describe Gitlab::Ci::Parsers::Security::DependencyScanning do
end
it "generates expected location fingerprint" do
expect(report.occurrences.first[:location_fingerprint]).to eq(fingerprint)
expect(report.occurrences.first.location_fingerprint).to eq(fingerprint)
end
it "generates expected metadata_version" do
expect(report.occurrences.first[:metadata_version]).to eq(version)
expect(report.occurrences.first.metadata_version).to eq(version)
end
end
......@@ -53,9 +53,9 @@ describe Gitlab::Ci::Parsers::Security::DependencyScanning do
it "generates occurrence with expected remediation" do
occurrence = report.occurrences.last
raw_metadata = JSON.parse!(occurrence[:raw_metadata])
raw_metadata = JSON.parse!(occurrence.raw_metadata)
expect(occurrence[:name]).to eq("Authentication bypass via incorrect DOM traversal and canonicalization in saml2-js")
expect(occurrence.name).to eq("Authentication bypass via incorrect DOM traversal and canonicalization in saml2-js")
expect(raw_metadata["remediations"].first["summary"]).to eq("Upgrade saml2-js")
expect(raw_metadata["remediations"].first["diff"]).to start_with("ZGlmZiAtLWdpdCBhL3lhcm4")
end
......
......@@ -27,11 +27,11 @@ describe Gitlab::Ci::Parsers::Security::Sast do
end
it "generates expected location fingerprint" do
expect(report.occurrences.first[:location_fingerprint]).to eq('d869ba3f0b3347eb2749135a437dc07c8ae0f420')
expect(report.occurrences.first.location_fingerprint).to eq('d869ba3f0b3347eb2749135a437dc07c8ae0f420')
end
it "generates expected metadata_version" do
expect(report.occurrences.first[:metadata_version]).to eq('1.2')
expect(report.occurrences.first.metadata_version).to eq('1.2')
end
end
end
......
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Ci::Reports::Security::Identifier do
describe '#initialize' do
subject { described_class.new(**params) }
let(:params) do
{
external_type: 'brakeman_warning_code',
external_id: '107',
name: 'Brakeman Warning Code 107',
url: 'https://brakemanscanner.org/docs/warning_types/cross_site_scripting/'
}
end
context 'when all params are given' do
it 'initializes an instance' do
expect { subject }.not_to raise_error
expect(subject).to have_attributes(
external_type: 'brakeman_warning_code',
external_id: '107',
fingerprint: 'aa2254904a69148ad14b6ac5db25b355da9c987f',
name: 'Brakeman Warning Code 107',
url: 'https://brakemanscanner.org/docs/warning_types/cross_site_scripting/'
)
end
end
%i[external_type external_id name].each do |attribute|
context "when attribute #{attribute} is missing" do
before do
params.delete(attribute)
end
it 'raises an error' do
expect { subject }.to raise_error(ArgumentError)
end
end
end
end
describe '#key' do
let(:identifier) { create(:ci_reports_security_identifier) }
subject { identifier.key }
it 'returns fingerprint' do
is_expected.to eq(identifier.fingerprint)
end
end
describe '#to_hash' do
let(:identifier) { create(:ci_reports_security_identifier) }
subject { identifier.to_hash }
it 'returns expected hash' do
is_expected.to eq({
external_type: identifier.external_type,
external_id: identifier.external_id,
fingerprint: identifier.fingerprint,
name: identifier.name,
url: identifier.url
})
end
end
describe '#==' do
using RSpec::Parameterized::TableSyntax
where(:type_1, :id_1, :type_2, :id_2, :equal, :case_name) do
'CVE' | '2018-1234' | 'CVE' | '2018-1234' | true | 'when external_type and external_id are equal'
'CVE' | '2018-1234' | 'brakeman_code' | '2018-1234' | false | 'when external_type is different'
'CVE' | '2018-1234' | 'CVE' | '2019-6789' | false | 'when external_id is different'
end
with_them do
let(:identifier_1) { create(:ci_reports_security_identifier, external_type: type_1, external_id: id_1) }
let(:identifier_2) { create(:ci_reports_security_identifier, external_type: type_2, external_id: id_2) }
it "returns #{params[:equal]}" do
expect(identifier_1 == identifier_2).to eq(equal)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Ci::Reports::Security::Occurrence do
describe '#initialize' do
subject { described_class.new(**params) }
let(:primary_identifier) { create(:ci_reports_security_identifier) }
let(:other_identifier) { create(:ci_reports_security_identifier) }
let(:scanner) { create(:ci_reports_security_scanner) }
let(:params) do
{
compare_key: 'this_is_supposed_to_be_a_unique_value',
confidence: :medium,
identifiers: [primary_identifier, other_identifier],
location_fingerprint: '4e5b6966dd100170b4b1ad599c7058cce91b57b4',
metadata_version: 'sast:1.0',
name: 'Cipher with no integrity',
raw_metadata: 'I am a stringified json object',
report_type: :sast,
scanner: scanner,
severity: :high,
uuid: 'cadf8cf0a8228fa92a0f4897a0314083bb38'
}
end
context 'when both all params are given' do
it 'initializes an instance' do
expect { subject }.not_to raise_error
expect(subject).to have_attributes(
compare_key: 'this_is_supposed_to_be_a_unique_value',
confidence: :medium,
project_fingerprint: '9a73f32d58d87d94e3dc61c4c1a94803f6014258',
identifiers: [primary_identifier, other_identifier],
location_fingerprint: '4e5b6966dd100170b4b1ad599c7058cce91b57b4',
metadata_version: 'sast:1.0',
name: 'Cipher with no integrity',
raw_metadata: 'I am a stringified json object',
report_type: :sast,
scanner: scanner,
severity: :high,
uuid: 'cadf8cf0a8228fa92a0f4897a0314083bb38'
)
end
end
%i[compare_key identifiers location_fingerprint metadata_version name raw_metadata report_type scanner uuid].each do |attribute|
context "when attribute #{attribute} is missing" do
before do
params.delete(attribute)
end
it 'raises an error' do
expect { subject }.to raise_error(ArgumentError)
end
end
end
end
describe '#to_hash' do
let(:occurrence) { create(:ci_reports_security_occurrence) }
subject { occurrence.to_hash }
it 'returns expected hash' do
is_expected.to eq({
compare_key: occurrence.compare_key,
confidence: occurrence.confidence,
identifiers: occurrence.identifiers,
location_fingerprint: occurrence.location_fingerprint,
metadata_version: occurrence.metadata_version,
name: occurrence.name,
project_fingerprint: occurrence.project_fingerprint,
raw_metadata: occurrence.raw_metadata,
report_type: occurrence.report_type,
scanner: occurrence.scanner,
severity: occurrence.severity,
uuid: occurrence.uuid
})
end
end
describe '#primary_identifier' do
let(:primary_identifier) { create(:ci_reports_security_identifier) }
let(:other_identifier) { create(:ci_reports_security_identifier) }
let(:occurrence) { create(:ci_reports_security_occurrence, identifiers: [primary_identifier, other_identifier]) }
subject { occurrence.primary_identifier }
it 'returns the first identifier' do
is_expected.to eq(primary_identifier)
end
end
describe '#==' do
using RSpec::Parameterized::TableSyntax
let(:identifier) { create(:ci_reports_security_identifier) }
let(:other_identifier) { create(:ci_reports_security_identifier, external_type: 'other_identifier') }
report_type = 'sast'
fingerprint = '4e5b6966dd100170b4b1ad599c7058cce91b57b4'
other_report_type = 'dependency_scanning'
other_fingerprint = '368d8604fb8c0g455d129274f5773aa2f31d4f7q'
where(:report_type_1, :location_fingerprint_1, :identifier_1, :report_type_2, :location_fingerprint_2, :identifier_2, :equal, :case_name) do
report_type | fingerprint | -> { identifier } | report_type | fingerprint | -> { identifier } | true | 'when report_type, location_fingerprint and primary identifier are equal'
report_type | fingerprint | -> { identifier } | other_report_type | fingerprint | -> { identifier } | false | 'when report_type is different'
report_type | fingerprint | -> { identifier } | report_type | other_fingerprint | -> { identifier } | false | 'when location_fingerprint is different'
report_type | fingerprint | -> { identifier } | report_type | fingerprint | -> { other_identifier } | false | 'when primary identifier is different'
end
with_them do
let(:occurrence_1) { create(:ci_reports_security_occurrence, report_type: report_type_1, location_fingerprint: location_fingerprint_1, identifiers: [identifier_1.call]) }
let(:occurrence_2) { create(:ci_reports_security_occurrence, report_type: report_type_2, location_fingerprint: location_fingerprint_2, identifiers: [identifier_2.call]) }
it "returns #{params[:equal]}" do
expect(occurrence_1 == occurrence_2).to eq(equal)
end
end
end
end
......@@ -9,7 +9,7 @@ describe Gitlab::Ci::Reports::Security::Report do
it { expect(report.type).to eq('sast') }
describe '#add_scanner' do
let(:scanner) { { external_id: 'find_sec_bugs' } }
let(:scanner) { create(:ci_reports_security_scanner, external_id: 'find_sec_bugs') }
subject { report.add_scanner(scanner) }
......@@ -19,29 +19,29 @@ describe Gitlab::Ci::Reports::Security::Report do
expect(report.scanners).to eq({ 'find_sec_bugs' => scanner })
end
it 'returns the map keyap' do
expect(subject).to eq('find_sec_bugs')
it 'returns the added scanner' do
expect(subject).to eq(scanner)
end
end
describe '#add_identifier' do
let(:identifier) { { fingerprint: '4e5b6966dd100170b4b1ad599c7058cce91b57b4' } }
let(:identifier) { create(:ci_reports_security_identifier) }
subject { report.add_identifier(identifier) }
it 'stores given identifier params in the map' do
subject
expect(report.identifiers).to eq({ '4e5b6966dd100170b4b1ad599c7058cce91b57b4' => identifier })
expect(report.identifiers).to eq({ identifier.fingerprint => identifier })
end
it 'returns the map keyap' do
expect(subject).to eq('4e5b6966dd100170b4b1ad599c7058cce91b57b4')
it 'returns the added identifier' do
expect(subject).to eq(identifier)
end
end
describe '#add_occurrence' do
let(:occurrence) { { foo: :bar } }
let(:occurrence) { create(:ci_reports_security_occurrence) }
it 'enriches given occurrence and stores it in the collection' do
report.add_occurrence(occurrence)
......
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Ci::Reports::Security::Scanner do
describe '#initialize' do
subject { described_class.new(**params) }
let(:params) do
{
external_id: 'brakeman',
name: 'Brakeman'
}
end
context 'when all params are given' do
it 'initializes an instance' do
expect { subject }.not_to raise_error
expect(subject).to have_attributes(
external_id: 'brakeman',
name: 'Brakeman'
)
end
end
%i[external_id name].each do |attribute|
context "when attribute #{attribute} is missing" do
before do
params.delete(attribute)
end
it 'raises an error' do
expect { subject }.to raise_error(ArgumentError)
end
end
end
end
describe '#key' do
let(:scanner) { create(:ci_reports_security_scanner) }
subject { scanner.key }
it 'returns external_id' do
is_expected.to eq(scanner.external_id)
end
end
describe '#to_hash' do
let(:scanner) { create(:ci_reports_security_scanner) }
subject { scanner.to_hash }
it 'returns expected hash' do
is_expected.to eq({
external_id: scanner.external_id,
name: scanner.name
})
end
end
describe '#==' do
using RSpec::Parameterized::TableSyntax
where(:id_1, :id_2, :equal, :case_name) do
'brakeman' | 'brakeman' | true | 'when external_id is equal'
'brakeman' | 'bandit' | false | 'when external_id is different'
end
with_them do
let(:scanner_1) { create(:ci_reports_security_scanner, external_id: id_1) }
let(:scanner_2) { create(:ci_reports_security_scanner, external_id: id_2) }
it "returns #{params[:equal]}" do
expect(scanner_1 == scanner_2).to eq(equal)
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment