Commit df51928f authored by Kamil Trzciński's avatar Kamil Trzciński

Merge branch '7586-use_poros_for_security_report_vulnerabilities' into 'master'

Use POROs for security report vulnerabilities

See merge request gitlab-org/gitlab-ee!10417
parents adfb33b8 d4ef47a2
......@@ -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