dradis-csv 5.2.0 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/app/assets/javascripts/dradis/plugins/csv/upload.js +46 -2
- data/app/controllers/dradis/plugins/csv/upload_controller.rb +39 -3
- data/app/jobs/dradis/plugins/csv/mapping_import_job.rb +3 -2
- data/app/views/dradis/plugins/csv/upload/new.html.erb +11 -2
- data/lib/dradis/plugins/csv/field_processor.rb +10 -0
- data/lib/dradis/plugins/csv/gem_version.rb +1 -1
- data/lib/dradis/plugins/csv/importer.rb +78 -17
- data/lib/dradis/plugins/csv/mapping.rb +77 -0
- data/lib/dradis/plugins/csv/mapping_form.rb +99 -0
- data/lib/dradis/plugins/csv.rb +3 -0
- data/spec/features/upload_spec.rb +66 -0
- data/spec/jobs/dradis/plugins/csv/mapping_import_job_spec.rb +4 -2
- data/spec/lib/dradis/plugins/csv/importer_spec.rb +92 -9
- data/spec/lib/dradis/plugins/csv/mapping_form_spec.rb +139 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9fc992eccc61f6f4f2b7a0d2696dbda946add4cb50a38cb92cc435dea0dc1038
|
|
4
|
+
data.tar.gz: 993fdee3bc19179b7f42801f6e0a078fa6e26d0e8eec09ae60a4e89006627e09
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7f3322fbe5e012b8bbe5eaab98450f71c3273461398f5e1d5e814c626152037216288fafe53614305789dd8a415c5ac5071bc6ed1b0ae01ee8268bd0f71964c1
|
|
7
|
+
data.tar.gz: be698324c4a89f500d9da324016e149b8d9d09842f5af2ae26a922130eb2fa21aab85d9b4baa6d76f3b3254d7ba28acb343d56a576d07dac3dc73492a87acf45
|
data/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,11 @@ window.addEventListener('job-done', function () {
|
|
|
14
14
|
Turbo.visit(redirectPath);
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
|
+
|
|
18
|
+
if ($('body.dradis-plugins-csv-upload.new').length) {
|
|
19
|
+
$('[data-behavior~=mapping-form] input[type="submit"]')
|
|
20
|
+
.val('Done!');
|
|
21
|
+
}
|
|
17
22
|
});
|
|
18
23
|
|
|
19
24
|
document.addEventListener('turbo:load', function() {
|
|
@@ -60,6 +65,16 @@ document.addEventListener('turbo:load', function() {
|
|
|
60
65
|
_setDradisFieldSelect($(this));
|
|
61
66
|
});
|
|
62
67
|
|
|
68
|
+
$('[data-behavior~=dradis-field-select]').on('change', function () {
|
|
69
|
+
console.log('change!');
|
|
70
|
+
_toggleCustomFieldInput($(this));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
$('[data-behavior~=dradis-field-select]').not(':disabled').each(function () {
|
|
74
|
+
console.log('initializing!');
|
|
75
|
+
_toggleCustomFieldInput($(this));
|
|
76
|
+
});
|
|
77
|
+
|
|
63
78
|
$('[data-behavior~=mapping-form]').submit(function () {
|
|
64
79
|
var valid = _validateIdentifierSelected() && _validateNodeSelected();
|
|
65
80
|
|
|
@@ -87,13 +102,21 @@ document.addEventListener('turbo:load', function() {
|
|
|
87
102
|
.attr('disabled', 'disabled')
|
|
88
103
|
.addClass('d-none');
|
|
89
104
|
|
|
105
|
+
$row
|
|
106
|
+
.find('[data-behavior~=custom-destination-input]')
|
|
107
|
+
.prop('disabled', true)
|
|
108
|
+
.addClass('d-none')
|
|
109
|
+
.attr('required', false);
|
|
110
|
+
|
|
111
|
+
var $activeSelect;
|
|
112
|
+
|
|
90
113
|
if ($select.val() == 'issue') {
|
|
91
|
-
$row
|
|
114
|
+
$activeSelect = $row
|
|
92
115
|
.find('[data-behavior~=issue-field-select]')
|
|
93
116
|
.removeAttr('disabled')
|
|
94
117
|
.removeClass('d-none');
|
|
95
118
|
} else if ($select.val() == 'evidence') {
|
|
96
|
-
$row
|
|
119
|
+
$activeSelect = $row
|
|
97
120
|
.find('[data-behavior~=evidence-field-select]')
|
|
98
121
|
.removeAttr('disabled')
|
|
99
122
|
.removeClass('d-none');
|
|
@@ -103,6 +126,27 @@ document.addEventListener('turbo:load', function() {
|
|
|
103
126
|
.attr('disabled', 'disabled')
|
|
104
127
|
.removeClass('d-none');
|
|
105
128
|
}
|
|
129
|
+
|
|
130
|
+
if ($activeSelect) {
|
|
131
|
+
_toggleCustomFieldInput($activeSelect);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function _toggleCustomFieldInput($select) {
|
|
136
|
+
var $customInput = $select.next('[data-behavior~=custom-destination-input]');
|
|
137
|
+
|
|
138
|
+
if (!$customInput.length) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
var isCustom = $select
|
|
143
|
+
.find('option:selected')
|
|
144
|
+
.is('[data-behavior~=custom-destination-field]');
|
|
145
|
+
|
|
146
|
+
$customInput
|
|
147
|
+
.toggleClass('d-none', !isCustom)
|
|
148
|
+
.prop('disabled', !isCustom)
|
|
149
|
+
.attr('required', isCustom);
|
|
106
150
|
}
|
|
107
151
|
|
|
108
152
|
function _validateNodeSelected() {
|
|
@@ -3,21 +3,31 @@ module Dradis::Plugins::CSV
|
|
|
3
3
|
include ProjectScoped
|
|
4
4
|
|
|
5
5
|
before_action :load_attachment, only: [:new, :create]
|
|
6
|
-
before_action :load_rtp_fields, only: [:new]
|
|
6
|
+
before_action :load_rtp_fields, only: [:new, :create]
|
|
7
7
|
before_action :load_csv_headers, only: [:new]
|
|
8
8
|
|
|
9
|
+
# Reached after the standard upload flow has already run the file
|
|
10
|
+
# through Importer#import. If a saved mapping matched, that import
|
|
11
|
+
# already happened (see importer.rb) and there's nothing left to map.
|
|
9
12
|
def new
|
|
10
|
-
|
|
13
|
+
if saved_mapping?
|
|
14
|
+
return redirect_to main_app.project_issues_path(current_project),
|
|
15
|
+
notice: 'CSV imported using its saved mapping.'
|
|
16
|
+
end
|
|
11
17
|
|
|
18
|
+
@default_columns = ['Column Header', 'Entity', 'Dradis Field']
|
|
12
19
|
@log_uid = Log.new.uid
|
|
13
20
|
end
|
|
14
21
|
|
|
15
22
|
def create
|
|
23
|
+
save_mapping
|
|
24
|
+
|
|
16
25
|
job_logger.write 'Enqueueing job to start in the background.'
|
|
17
26
|
|
|
18
27
|
MappingImportJob.perform_later(
|
|
19
28
|
default_user_id: current_user.id,
|
|
20
29
|
file: @attachment.fullpath.to_s,
|
|
30
|
+
headers: csv_headers,
|
|
21
31
|
mappings: mappings_params[:field_attributes].to_h,
|
|
22
32
|
project_id: current_project.id,
|
|
23
33
|
state: state,
|
|
@@ -31,6 +41,10 @@ module Dradis::Plugins::CSV
|
|
|
31
41
|
@job_logger ||= Log.new(uid: params[:log_uid].to_i)
|
|
32
42
|
end
|
|
33
43
|
|
|
44
|
+
def csv_headers
|
|
45
|
+
@csv_headers ||= ::CSV.open(@attachment.fullpath, &:readline)
|
|
46
|
+
end
|
|
47
|
+
|
|
34
48
|
def load_attachment
|
|
35
49
|
filename = CGI::escape params[:attachment]
|
|
36
50
|
@attachment = Attachment.find(filename, conditions: { node_id: current_project.plugin_uploads_node.id })
|
|
@@ -62,7 +76,29 @@ module Dradis::Plugins::CSV
|
|
|
62
76
|
end
|
|
63
77
|
|
|
64
78
|
def mappings_params
|
|
65
|
-
params.require(:mappings).permit(field_attributes: [:field, :type])
|
|
79
|
+
params.require(:mappings).permit(field_attributes: [:field, :type, :custom_field])
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def rtp_destination
|
|
83
|
+
rtp = current_project.report_template_properties
|
|
84
|
+
rtp && rtp.as_mapping_destination
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Persist the submitted column assignments so future uploads of this CSV
|
|
88
|
+
# format can reuse them. Mappings are scoped to a report template, so
|
|
89
|
+
# projects without one keep the upload-time mapper only.
|
|
90
|
+
def save_mapping
|
|
91
|
+
return unless rtp_destination
|
|
92
|
+
|
|
93
|
+
MappingForm.new(
|
|
94
|
+
destination: rtp_destination,
|
|
95
|
+
headers: csv_headers,
|
|
96
|
+
rtp_fields: @rtp_fields
|
|
97
|
+
).save(column_mappings: mappings_params[:field_attributes].to_h)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def saved_mapping?
|
|
101
|
+
rtp_destination && Dradis::Plugins::CSV.mapping_exists?(headers: @headers, destination: rtp_destination)
|
|
66
102
|
end
|
|
67
103
|
|
|
68
104
|
def state
|
|
@@ -13,7 +13,7 @@ module Dradis::Plugins::CSV
|
|
|
13
13
|
# '2' => { 'type' => 'identifier' },
|
|
14
14
|
# '3' => { 'type' => 'evidence', 'field' => 'Port' }
|
|
15
15
|
# }
|
|
16
|
-
def perform(default_user_id:, file:, mappings:, project_id:, state:, uid:)
|
|
16
|
+
def perform(default_user_id:, file:, headers:, mappings:, project_id:, state:, uid:)
|
|
17
17
|
logger = Log.new(uid: uid)
|
|
18
18
|
logger.write { "Job id is #{job_id}." }
|
|
19
19
|
|
|
@@ -25,7 +25,8 @@ module Dradis::Plugins::CSV
|
|
|
25
25
|
state: state
|
|
26
26
|
)
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
logger.write { 'Worker process starting background task.' }
|
|
29
|
+
importer.import_rows(file: file, headers: headers, mappings: mappings)
|
|
29
30
|
|
|
30
31
|
logger.write { 'Worker process completed.' }
|
|
31
32
|
end
|
|
@@ -20,6 +20,12 @@
|
|
|
20
20
|
<div class="alert alert-danger d-none" data-behavior="node-type-validation-message">
|
|
21
21
|
<p>A Node Label must be selected to import evidence records.</p>
|
|
22
22
|
</div>
|
|
23
|
+
|
|
24
|
+
<% if @rtp_fields && (@rtp_fields[:issue].present? || @rtp_fields[:evidence].present?) %>
|
|
25
|
+
<div class="alert alert-info">
|
|
26
|
+
<p class="m-0">This mapping will be saved and applied automatically the next time you upload a CSV file with these same column headers.</p>
|
|
27
|
+
</div>
|
|
28
|
+
<% end %>
|
|
23
29
|
</div>
|
|
24
30
|
|
|
25
31
|
<%= form_with url: project_upload_index_path(current_project, format: :js), method: :post, local: false, data: { behavior: 'mapping-form' } do |f| %>
|
|
@@ -37,6 +43,7 @@
|
|
|
37
43
|
</tr>
|
|
38
44
|
</thead>
|
|
39
45
|
<tbody>
|
|
46
|
+
<% custom_field_option = ['Custom Field', 'Custom Field', { data: { behavior: 'custom-destination-field' } }] %>
|
|
40
47
|
<% @headers.each_with_index do |header, index| %>
|
|
41
48
|
<tr class="issue-type">
|
|
42
49
|
<td><%= header %></td>
|
|
@@ -48,11 +55,13 @@
|
|
|
48
55
|
<td>
|
|
49
56
|
<% if @rtp_fields %>
|
|
50
57
|
<div>
|
|
51
|
-
<% issue_options = @rtp_fields[:issue].any? ? options_for_select(@rtp_fields[:issue]) : options_for_select([[header, header]], disabled: header, selected: header) %>
|
|
58
|
+
<% issue_options = @rtp_fields[:issue].any? ? options_for_select(@rtp_fields[:issue] + [custom_field_option]) : options_for_select([[header, header]], disabled: header, selected: header) %>
|
|
52
59
|
<%= f.select "mappings[field_attributes][#{index}][field]", issue_options, {}, class: 'form-select w-75 field-select', data: { behavior: 'dradis-field-select issue-field-select', header: header, 'combobox-config': 'no-combobox' } %>
|
|
60
|
+
<%= f.text_field "mappings[field_attributes][#{index}][custom_field]", disabled: true, class: 'form-control w-75 mt-1 custom-field-input d-none', style: 'min-width: 0;', placeholder: 'Enter field name', data: { behavior: 'custom-destination-input' } %>
|
|
53
61
|
|
|
54
|
-
<% evidence_options = @rtp_fields[:evidence].any? ? options_for_select(@rtp_fields[:evidence]) : options_for_select([[header, header]], disabled: header, selected: header) %>
|
|
62
|
+
<% evidence_options = @rtp_fields[:evidence].any? ? options_for_select(@rtp_fields[:evidence] + [custom_field_option]) : options_for_select([[header, header]], disabled: header, selected: header) %>
|
|
55
63
|
<%= f.select "mappings[field_attributes][#{index}][field]", evidence_options, {}, disabled: true, class: 'form-select w-75 field-select d-none', data: { behavior: 'dradis-field-select evidence-field-select', header: header, 'combobox-config': 'no-combobox' } %>
|
|
64
|
+
<%= f.text_field "mappings[field_attributes][#{index}][custom_field]", disabled: true, class: 'form-control w-75 mt-1 custom-field-input d-none', style: 'min-width: 0;', placeholder: 'Enter field name', data: { behavior: 'custom-destination-input' } %>
|
|
56
65
|
|
|
57
66
|
<%= f.select "mappings[field_attributes][#{index}][field]", [['N/A', '']], {}, disabled: true, class: 'form-select w-75 field-select d-none', data: { behavior: 'dradis-field-select empty-field-select', header: header, 'combobox-config': 'no-combobox' } %>
|
|
58
67
|
</div>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module Dradis::Plugins::CSV
|
|
2
|
+
class FieldProcessor < Dradis::Plugins::Upload::FieldProcessor
|
|
3
|
+
# data is a Hash of the current row, keyed by normalized header (see
|
|
4
|
+
# Importer#normalized_row), matching how MappingForm normalizes
|
|
5
|
+
# headers when it stores each field's source_field/content.
|
|
6
|
+
def value(args = {})
|
|
7
|
+
data[args[:field]]
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -1,29 +1,54 @@
|
|
|
1
1
|
module Dradis::Plugins::CSV
|
|
2
2
|
class Importer < Dradis::Plugins::Upload::Importer
|
|
3
|
+
# Sources are dynamic (one per mapped CSV format), so report the
|
|
4
|
+
# currently known sources grouped by entity for the Mappings Manager.
|
|
3
5
|
def self.templates
|
|
4
|
-
|
|
6
|
+
sources = Dradis::Plugins::CSV.mapping_sources.map(&:to_s)
|
|
7
|
+
|
|
8
|
+
{
|
|
9
|
+
evidence: sources.grep(/\Aevidence_/),
|
|
10
|
+
issue: sources.grep(/\Aissue_/)
|
|
11
|
+
}
|
|
5
12
|
end
|
|
6
13
|
|
|
7
|
-
|
|
8
|
-
|
|
14
|
+
# Runs as part of the standard upload flow, before the user would reach
|
|
15
|
+
# the column mapper. If this project already has a saved mapping for the
|
|
16
|
+
# file's headers, import immediately using it, so a recognized format
|
|
17
|
+
# never needs re-mapping and gets the same treatment (Rules Engine
|
|
18
|
+
# included) as any other plugin's upload. Unrecognized formats no-op
|
|
19
|
+
# here; UploadController sends the user to the mapper instead.
|
|
20
|
+
def import(params = {})
|
|
21
|
+
return false if mapping_service.destination.blank?
|
|
22
|
+
|
|
23
|
+
headers = CSV.open(params[:file], &:readline)
|
|
9
24
|
|
|
10
|
-
|
|
25
|
+
unless Dradis::Plugins::CSV.mapping_exists?(headers: headers, destination: mapping_service.destination)
|
|
26
|
+
logger.info { 'No saved mapping found for this CSV format.' }
|
|
27
|
+
return false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
import_rows(file: params[:file], headers: headers)
|
|
11
31
|
end
|
|
12
32
|
|
|
13
|
-
|
|
14
|
-
|
|
33
|
+
# Entry point for both the column mapper form submission (see
|
|
34
|
+
# MappingImportJob) and the auto-recognized import above. mappings is the
|
|
35
|
+
# raw form submission and is only consulted as a fallback, for whichever
|
|
36
|
+
# of identifier/node/entity fields has no saved Mapping to read from
|
|
37
|
+
# instead (e.g. no RTP is set, so MappingForm never persisted one).
|
|
38
|
+
def import_rows(file:, headers:, mappings: nil)
|
|
39
|
+
filename = File.basename(file)
|
|
40
|
+
@issue_lookup = {}
|
|
15
41
|
|
|
16
|
-
|
|
42
|
+
@issue_source, @issue_fields, id_index = mapped_fields(entity: :issue, headers: headers)
|
|
43
|
+
@evidence_source, @evidence_fields, node_index = mapped_fields(entity: :evidence, headers: headers)
|
|
17
44
|
|
|
18
|
-
|
|
19
|
-
id_index
|
|
45
|
+
mappings_groups = (mappings || {}).group_by { |index, mapping| mapping['type'] }
|
|
46
|
+
id_index ||= Integer(mappings_groups['identifier']&.first&.first, exception: false)
|
|
47
|
+
@node_index = node_index || Integer(mappings_groups['node']&.first&.first, exception: false)
|
|
20
48
|
@evidence_mappings = mappings_groups['evidence'] || []
|
|
21
|
-
@issue_lookup = {}
|
|
22
49
|
@issue_mappings = mappings_groups['issue'] || []
|
|
23
|
-
@node_index = Integer(mappings_groups['node']&.first&.first, exception: false)
|
|
24
|
-
|
|
25
50
|
|
|
26
|
-
CSV.foreach(
|
|
51
|
+
CSV.foreach(file, headers: true).with_index do |row, index|
|
|
27
52
|
csv_id = row[id_index] || "#{filename}-#{index}"
|
|
28
53
|
process_issue(csv_id: csv_id, row: row)
|
|
29
54
|
process_node(csv_id: csv_id, row: row)
|
|
@@ -34,9 +59,39 @@ module Dradis::Plugins::CSV
|
|
|
34
59
|
|
|
35
60
|
private
|
|
36
61
|
|
|
37
|
-
attr_accessor :
|
|
62
|
+
attr_accessor :evidence_fields, :evidence_mappings, :evidence_source,
|
|
63
|
+
:issue_fields, :issue_lookup, :issue_mappings, :issue_source, :node_index
|
|
64
|
+
|
|
65
|
+
# The saved Mapping's fields (with their content templates) for this
|
|
66
|
+
# entity, excluding the reserved identifier/node field (that drives
|
|
67
|
+
# csv_id/node_label directly in import_rows, not entity content) but
|
|
68
|
+
# returning its column index instead, so import_rows can read it
|
|
69
|
+
# straight off the Mapping rather than the column mapper form. Returns
|
|
70
|
+
# all nils when there's no RTP or no saved mapping, so build_text falls
|
|
71
|
+
# back to reading the CSV directly instead of applying a template, and
|
|
72
|
+
# import_rows falls back to the form for the reserved index too.
|
|
73
|
+
def mapped_fields(entity:, headers:)
|
|
74
|
+
return [nil, nil, nil] unless mapping_service.destination.present?
|
|
75
|
+
|
|
76
|
+
source = Dradis::Plugins::CSV.mapping_source(headers: headers, entity: entity)
|
|
77
|
+
mapping = Dradis::Plugins::CSV.get_mapping(source: source, destination: mapping_service.destination)
|
|
78
|
+
|
|
79
|
+
return [nil, nil, nil] unless mapping
|
|
80
|
+
|
|
81
|
+
reserved_field = entity == :issue ? Mapping::IDENTIFIER_FIELD : Mapping::NODE_LABEL_FIELD
|
|
82
|
+
reserved, fields = mapping.mapping_fields.partition { |field| field.destination_field == reserved_field }
|
|
83
|
+
reserved = reserved.first
|
|
84
|
+
|
|
85
|
+
reserved_index = reserved && headers.index do |header|
|
|
86
|
+
Dradis::Plugins::CSV.normalize_header(header) == reserved.source_field
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
[source, fields, reserved_index]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def build_text(mappings:, source:, fields:, row:)
|
|
93
|
+
return apply_template(source: source, fields: fields, row: row) if fields
|
|
38
94
|
|
|
39
|
-
def build_text(mappings:, row:)
|
|
40
95
|
mappings.map do |index, mapping|
|
|
41
96
|
next if project.report_template_properties && mapping['field'].blank?
|
|
42
97
|
|
|
@@ -46,17 +101,23 @@ module Dradis::Plugins::CSV
|
|
|
46
101
|
end.compact.join("\n\n")
|
|
47
102
|
end
|
|
48
103
|
|
|
104
|
+
def apply_template(source:, fields:, row:)
|
|
105
|
+
data = row.to_h.transform_keys { |header| Dradis::Plugins::CSV.normalize_header(header) }
|
|
106
|
+
|
|
107
|
+
mapping_service.apply_mapping(source: source, data: data, mapping_fields: fields)
|
|
108
|
+
end
|
|
109
|
+
|
|
49
110
|
def process_evidence(csv_id:, node:, row:)
|
|
50
111
|
logger.info{ "\t\t => Creating evidence: (node: #{node.label}, plugin_id: #{csv_id})" }
|
|
51
112
|
|
|
52
113
|
issue = issue_lookup[csv_id]
|
|
53
|
-
evidence_content = build_text(mappings:
|
|
114
|
+
evidence_content = build_text(mappings: evidence_mappings, source: evidence_source, fields: evidence_fields, row: row)
|
|
54
115
|
content_service.create_evidence(issue: issue, node: node, content: evidence_content)
|
|
55
116
|
end
|
|
56
117
|
|
|
57
118
|
def process_issue(csv_id:, row:)
|
|
58
119
|
logger.info { "\t => Creating new issue (plugin_id: #{csv_id})" }
|
|
59
|
-
issue_text = build_text(mappings: issue_mappings, row: row)
|
|
120
|
+
issue_text = build_text(mappings: issue_mappings, source: issue_source, fields: issue_fields, row: row)
|
|
60
121
|
issue = content_service.create_issue(text: issue_text, id: csv_id)
|
|
61
122
|
|
|
62
123
|
issue_lookup[csv_id] = issue
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module Dradis::Plugins::CSV
|
|
2
|
+
# Unlike other integrations, CSV files don't have a fixed structure, so the
|
|
3
|
+
# list of sources can't be defined upfront. Instead, a new source is
|
|
4
|
+
# registered every time a user maps a new CSV format (i.e. a new set of
|
|
5
|
+
# column headers) through the column mapper (see MappingForm). The class
|
|
6
|
+
# methods below give Dradis::Plugins::Mappings::Base's default,
|
|
7
|
+
# constant-backed implementations (mapping_sources, source_fields,
|
|
8
|
+
# default_mapping) database-backed overrides instead, so there's no
|
|
9
|
+
# DEFAULT_MAPPING/SOURCE_FIELDS constant here for them to fall back to.
|
|
10
|
+
module Mapping
|
|
11
|
+
# Reserved destination fields that carry row metadata instead of entity
|
|
12
|
+
# content. They're stored alongside the content fields but are excluded
|
|
13
|
+
# from the entity text during import.
|
|
14
|
+
IDENTIFIER_FIELD = 'plugin_id'.freeze
|
|
15
|
+
NODE_LABEL_FIELD = 'node_label'.freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.default_mapping(_source)
|
|
19
|
+
{}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Excludes the reserved identifier/node fields from the destination field
|
|
23
|
+
# list: they carry row metadata, not entity content (see Importer#mapped_fields,
|
|
24
|
+
# which excludes them the same way when building the import template).
|
|
25
|
+
def self.field_names(source:, destination: nil, field_type: 'destination')
|
|
26
|
+
super.reject do |field|
|
|
27
|
+
field_type == 'destination' &&
|
|
28
|
+
[Mapping::IDENTIFIER_FIELD, Mapping::NODE_LABEL_FIELD].include?(field)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Whether a format matching these headers has already been mapped for this
|
|
33
|
+
# destination, i.e. whether either its issue or evidence Mapping (or both)
|
|
34
|
+
# was saved. Shared by Importer#import (to recognize a format on upload)
|
|
35
|
+
# and UploadController (to skip the mapper for a recognized format).
|
|
36
|
+
def self.mapping_exists?(headers:, destination:)
|
|
37
|
+
sources = %i[issue evidence].map { |entity| mapping_source(headers: headers, entity: entity) }
|
|
38
|
+
|
|
39
|
+
::Mapping.exists?(component: component, source: sources, destination: destination)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.mapping_sources
|
|
43
|
+
::Mapping.where(component: component).distinct.pluck(:source).map(&:to_sym)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The source name for a CSV format: the entity (issue/evidence) the mapping
|
|
47
|
+
# populates, plus its normalized headers joined with '/', so the Mappings
|
|
48
|
+
# Manager shows something recognizable (e.g. issue_severity/title) instead
|
|
49
|
+
# of an opaque digest. Headers are sorted so reordering columns doesn't
|
|
50
|
+
# produce a new source; any '/' inside a header name is replaced so it
|
|
51
|
+
# can't be mistaken for the join separator. The entity prefix also lets
|
|
52
|
+
# Importer.templates group sources by entity with a simple regex.
|
|
53
|
+
def self.mapping_source(headers:, entity:)
|
|
54
|
+
normalized = headers.map { |header| normalize_header(header).gsub('/', '-') }.sort
|
|
55
|
+
|
|
56
|
+
"#{entity}_#{normalized.join('/')}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.normalize_header(header)
|
|
60
|
+
header.to_s.delete(" \t\r\n")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# We're building a dynamic source here since we can't rely on a fixed set of
|
|
64
|
+
# sources unlike the other integrations.
|
|
65
|
+
def self.sample(source)
|
|
66
|
+
source_fields(source).index_with { |field| field }.to_json
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.source_fields(source)
|
|
70
|
+
::MappingField.
|
|
71
|
+
joins(:mapping).
|
|
72
|
+
where(mappings: { component: component, source: source.to_s }).
|
|
73
|
+
where.not(source_field: 'Custom Text').
|
|
74
|
+
distinct.
|
|
75
|
+
pluck(:source_field)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
module Dradis::Plugins::CSV
|
|
2
|
+
# Converts the column mapper's form structure into persisted Mapping
|
|
3
|
+
# records, one per entity (issue/evidence), so a CSV format only needs to
|
|
4
|
+
# be mapped once: #save is the forward direction (form -> records), run
|
|
5
|
+
# the first time a format is mapped. Later uploads of a recognized format
|
|
6
|
+
# read straight from those records instead (see Importer#mapped_fields),
|
|
7
|
+
# rather than reconstructing the form.
|
|
8
|
+
class MappingForm
|
|
9
|
+
# Mapper column types that produce fields for each entity: identifier
|
|
10
|
+
# columns are stored with the issue mapping, node columns with evidence.
|
|
11
|
+
ENTITY_TYPES = {
|
|
12
|
+
issue: %w[issue identifier],
|
|
13
|
+
evidence: %w[evidence node]
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :column_mappings, :destination, :headers, :rtp_fields
|
|
17
|
+
|
|
18
|
+
# rtp_fields is { issue: [...field names...], evidence: [...] }: the
|
|
19
|
+
# destination fields actually defined on the RTP, used to gate #save (see
|
|
20
|
+
# below).
|
|
21
|
+
def initialize(destination:, headers:, rtp_fields:)
|
|
22
|
+
@destination = destination
|
|
23
|
+
@headers = headers
|
|
24
|
+
@rtp_fields = rtp_fields
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Persists one Mapping per entity that has assigned columns, so a
|
|
28
|
+
# newly-mapped CSV format is recognized automatically on future uploads
|
|
29
|
+
# (see Importer#import). Only ever called for formats with no existing
|
|
30
|
+
# mapping: Importer#import and UploadController#new short-circuit before
|
|
31
|
+
# the mapper is reached once a format already has a saved mapping.
|
|
32
|
+
#
|
|
33
|
+
# column_mappings is the mapper form submission, keyed by column index:
|
|
34
|
+
# {
|
|
35
|
+
# '0' => { 'type' => 'node' },
|
|
36
|
+
# '1' => { 'type' => 'issue', 'field' => 'Title' },
|
|
37
|
+
# '2' => { 'type' => 'identifier' },
|
|
38
|
+
# '3' => { 'type' => 'evidence', 'field' => 'Port' }
|
|
39
|
+
# }
|
|
40
|
+
#
|
|
41
|
+
# An entity with no RTP fields defined has nowhere valid to point a
|
|
42
|
+
# mapping, so it's skipped entirely (including its identifier/node
|
|
43
|
+
# column, if any) rather than saving a mapping with a bogus destination.
|
|
44
|
+
def save(column_mappings:)
|
|
45
|
+
@column_mappings = column_mappings
|
|
46
|
+
|
|
47
|
+
return if destination.blank?
|
|
48
|
+
|
|
49
|
+
::Mapping.transaction do
|
|
50
|
+
ENTITY_TYPES.keys.each do |entity|
|
|
51
|
+
next if rtp_fields[entity].blank?
|
|
52
|
+
|
|
53
|
+
fields = fields_for(entity)
|
|
54
|
+
next if fields.empty?
|
|
55
|
+
|
|
56
|
+
source = Dradis::Plugins::CSV.mapping_source(headers: headers, entity: entity)
|
|
57
|
+
mapping = ::Mapping.create!(component: component, source: source, destination: destination)
|
|
58
|
+
mapping.mapping_fields.create!(fields)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def component
|
|
66
|
+
Dradis::Plugins::CSV.component
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def fields_for(entity)
|
|
70
|
+
types = ENTITY_TYPES.fetch(entity)
|
|
71
|
+
|
|
72
|
+
column_mappings.filter_map do |index, assignment|
|
|
73
|
+
next unless types.include?(assignment['type'])
|
|
74
|
+
|
|
75
|
+
header = Dradis::Plugins::CSV.normalize_header(headers[index.to_i])
|
|
76
|
+
next if header.blank?
|
|
77
|
+
|
|
78
|
+
destination_field =
|
|
79
|
+
case assignment['type']
|
|
80
|
+
when 'identifier'
|
|
81
|
+
Mapping::IDENTIFIER_FIELD
|
|
82
|
+
when 'node'
|
|
83
|
+
Mapping::NODE_LABEL_FIELD
|
|
84
|
+
else
|
|
85
|
+
field = assignment['field'] == 'Custom Field' ? assignment['custom_field'] : assignment['field']
|
|
86
|
+
next if field.blank?
|
|
87
|
+
|
|
88
|
+
field
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
{
|
|
92
|
+
source_field: header,
|
|
93
|
+
content: "{{ csv[#{header}] }}",
|
|
94
|
+
destination_field: destination_field
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
data/lib/dradis/plugins/csv.rb
CHANGED
|
@@ -7,5 +7,8 @@ module Dradis
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
require 'dradis/plugins/csv/engine'
|
|
10
|
+
require 'dradis/plugins/csv/field_processor'
|
|
10
11
|
require 'dradis/plugins/csv/importer'
|
|
12
|
+
require 'dradis/plugins/csv/mapping'
|
|
13
|
+
require 'dradis/plugins/csv/mapping_form'
|
|
11
14
|
require 'dradis/plugins/csv/version'
|
|
@@ -129,6 +129,8 @@ describe 'upload feature', js: true do
|
|
|
129
129
|
|
|
130
130
|
evidence = node.evidence.first
|
|
131
131
|
expect(evidence.fields).to eq({ 'Label' => '10.0.0.1', 'Title' => 'SQL Injection', 'Location' => '10.0.0.1', 'Port' => '443' })
|
|
132
|
+
|
|
133
|
+
expect(Mapping.where(component: 'csv')).to be_empty
|
|
132
134
|
end
|
|
133
135
|
end
|
|
134
136
|
end
|
|
@@ -235,6 +237,11 @@ describe 'upload feature', js: true do
|
|
|
235
237
|
|
|
236
238
|
evidence = node.evidence.first
|
|
237
239
|
expect(evidence.fields).to eq({ 'Label' => '10.0.0.1', 'Location' => '10.0.0.1', 'Title' => 'SQL Injection', 'Port' => '443' })
|
|
240
|
+
|
|
241
|
+
headers = CSV.open(file_path, &:readline)
|
|
242
|
+
issue_source = Dradis::Plugins::CSV.mapping_source(headers: headers, entity: :issue)
|
|
243
|
+
evidence_source = Dradis::Plugins::CSV.mapping_source(headers: headers, entity: :evidence)
|
|
244
|
+
expect(Mapping.where(component: 'csv').pluck(:source)).to match_array([issue_source, evidence_source])
|
|
238
245
|
end
|
|
239
246
|
end
|
|
240
247
|
end
|
|
@@ -282,6 +289,65 @@ describe 'upload feature', js: true do
|
|
|
282
289
|
end
|
|
283
290
|
end
|
|
284
291
|
|
|
292
|
+
context 'uploading a CSV file with a saved mapping' do
|
|
293
|
+
let(:file_path) { File.expand_path('../fixtures/files/simple.csv', __dir__) }
|
|
294
|
+
|
|
295
|
+
before do
|
|
296
|
+
rtp = create(
|
|
297
|
+
:report_template_properties,
|
|
298
|
+
evidence_fields: [{ name: 'Location', type: :string, default: true }],
|
|
299
|
+
issue_fields: [{ name: 'Title', type: :string, default: true }]
|
|
300
|
+
)
|
|
301
|
+
@project.update(report_template_properties: rtp)
|
|
302
|
+
|
|
303
|
+
headers = CSV.open(file_path, &:readline)
|
|
304
|
+
issue_mapping = Mapping.create!(
|
|
305
|
+
component: 'csv',
|
|
306
|
+
source: Dradis::Plugins::CSV.mapping_source(headers: headers, entity: :issue),
|
|
307
|
+
destination: rtp.as_mapping_destination
|
|
308
|
+
)
|
|
309
|
+
issue_mapping.mapping_fields.create!(
|
|
310
|
+
source_field: 'Id', destination_field: 'plugin_id', content: '{{ csv[Id] }}'
|
|
311
|
+
)
|
|
312
|
+
issue_mapping.mapping_fields.create!(
|
|
313
|
+
source_field: 'Title', destination_field: 'Title', content: '{{ csv[Title] }}'
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
evidence_mapping = Mapping.create!(
|
|
317
|
+
component: 'csv',
|
|
318
|
+
source: Dradis::Plugins::CSV.mapping_source(headers: headers, entity: :evidence),
|
|
319
|
+
destination: rtp.as_mapping_destination
|
|
320
|
+
)
|
|
321
|
+
evidence_mapping.mapping_fields.create!(
|
|
322
|
+
source_field: 'Host', destination_field: 'node_label', content: '{{ csv[Host] }}'
|
|
323
|
+
)
|
|
324
|
+
evidence_mapping.mapping_fields.create!(
|
|
325
|
+
source_field: 'Location', destination_field: 'Location', content: '{{ csv[Location] }}'
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
page.refresh
|
|
329
|
+
|
|
330
|
+
find('#uploader + .combobox').click
|
|
331
|
+
find('#uploader ~ .combobox-menu .combobox-option', text: 'Dradis::Plugins::CSV').click
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
it 'imports immediately using the saved mapping, without showing the mapper' do
|
|
335
|
+
attach_file 'file', file_path, visible: false, disabled: false
|
|
336
|
+
|
|
337
|
+
expect(page).to have_text('CSV imported using its saved mapping', wait: 30)
|
|
338
|
+
expect(current_path).to eq(main_app.project_upload_manager_path(@project))
|
|
339
|
+
|
|
340
|
+
issue = Issue.last
|
|
341
|
+
expect(issue.fields).to include('Title' => 'SQL Injection', 'plugin' => 'csv', 'plugin_id' => '1')
|
|
342
|
+
|
|
343
|
+
node = issue.affected.first
|
|
344
|
+
expect(node.label).to eq('10.0.0.1')
|
|
345
|
+
|
|
346
|
+
evidence = node.evidence.first
|
|
347
|
+
expect(evidence.fields).to include('Location' => '10.0.0.1')
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
285
351
|
describe 'CSV file samples' do
|
|
286
352
|
before do
|
|
287
353
|
find('#uploader + .combobox').click
|
|
@@ -7,17 +7,19 @@ RSpec.describe Dradis::Plugins::CSV::MappingImportJob do
|
|
|
7
7
|
described_class.new.perform(
|
|
8
8
|
default_user_id: create(:user).id,
|
|
9
9
|
file: file,
|
|
10
|
+
headers: CSV.open(file, &:readline),
|
|
10
11
|
mappings: {},
|
|
11
12
|
project_id: create(:project).id,
|
|
13
|
+
state: 'draft',
|
|
12
14
|
uid: 1
|
|
13
15
|
)
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
describe '#perform' do
|
|
17
|
-
it 'calls Importer#
|
|
19
|
+
it 'calls Importer#import_rows' do
|
|
18
20
|
dbl = double('Importer')
|
|
19
21
|
allow(Dradis::Plugins::CSV::Importer).to receive(:new).and_return(dbl)
|
|
20
|
-
expect(dbl).to receive(:
|
|
22
|
+
expect(dbl).to receive(:import_rows).and_return(true)
|
|
21
23
|
|
|
22
24
|
perform_job
|
|
23
25
|
end
|
|
@@ -2,6 +2,7 @@ require 'rails_helper'
|
|
|
2
2
|
|
|
3
3
|
RSpec.describe Dradis::Plugins::CSV::Importer do
|
|
4
4
|
let(:file) { File.expand_path('../../../.../../../fixtures/files/simple.csv', __dir__) }
|
|
5
|
+
let(:headers) { CSV.open(file, &:readline) }
|
|
5
6
|
let(:project) { create(:project) }
|
|
6
7
|
|
|
7
8
|
let(:instance) do
|
|
@@ -13,11 +14,11 @@ RSpec.describe Dradis::Plugins::CSV::Importer do
|
|
|
13
14
|
)
|
|
14
15
|
end
|
|
15
16
|
|
|
16
|
-
let(:
|
|
17
|
-
instance.
|
|
17
|
+
let(:import_rows) do
|
|
18
|
+
instance.import_rows(file: file, headers: headers, mappings: mappings)
|
|
18
19
|
end
|
|
19
20
|
|
|
20
|
-
describe '#
|
|
21
|
+
describe '#import_rows' do
|
|
21
22
|
context 'when project has RTP' do
|
|
22
23
|
let(:mappings) do
|
|
23
24
|
{
|
|
@@ -34,7 +35,7 @@ RSpec.describe Dradis::Plugins::CSV::Importer do
|
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
it 'uses the field as Dradis Field' do
|
|
37
|
-
|
|
38
|
+
import_rows
|
|
38
39
|
|
|
39
40
|
issue = Issue.first
|
|
40
41
|
expect(issue.fields).to eq({ 'MyTitle' => 'SQL Injection', 'plugin' => 'csv', 'plugin_id' => '1' })
|
|
@@ -60,7 +61,7 @@ RSpec.describe Dradis::Plugins::CSV::Importer do
|
|
|
60
61
|
end
|
|
61
62
|
|
|
62
63
|
it 'uses the column name as Dradis Field' do
|
|
63
|
-
|
|
64
|
+
import_rows
|
|
64
65
|
|
|
65
66
|
issue = Issue.first
|
|
66
67
|
expect(issue.fields).to eq({ 'Title' => 'SQL Injection', 'VulnerabilityCategory' => 'High', 'plugin' => 'csv', 'plugin_id' => '1' })
|
|
@@ -73,7 +74,7 @@ RSpec.describe Dradis::Plugins::CSV::Importer do
|
|
|
73
74
|
end
|
|
74
75
|
|
|
75
76
|
it 'strips out whitespace from column header' do
|
|
76
|
-
|
|
77
|
+
import_rows
|
|
77
78
|
|
|
78
79
|
issue = Issue.first
|
|
79
80
|
expect(issue.fields.keys).to include('VulnerabilityCategory')
|
|
@@ -90,7 +91,7 @@ RSpec.describe Dradis::Plugins::CSV::Importer do
|
|
|
90
91
|
end
|
|
91
92
|
|
|
92
93
|
it 'does not create node and evidence' do
|
|
93
|
-
|
|
94
|
+
import_rows
|
|
94
95
|
|
|
95
96
|
issue = Issue.last
|
|
96
97
|
expect(issue.affected.length).to eq(0)
|
|
@@ -107,7 +108,7 @@ RSpec.describe Dradis::Plugins::CSV::Importer do
|
|
|
107
108
|
end
|
|
108
109
|
|
|
109
110
|
it 'uses filename and row index as csv_id' do
|
|
110
|
-
|
|
111
|
+
import_rows
|
|
111
112
|
|
|
112
113
|
issue = Issue.last
|
|
113
114
|
expect(issue.fields).to eq({ 'Title' => 'SQL Injection', 'plugin' => 'csv', 'plugin_id' => 'simple.csv-0' })
|
|
@@ -124,7 +125,7 @@ RSpec.describe Dradis::Plugins::CSV::Importer do
|
|
|
124
125
|
end
|
|
125
126
|
|
|
126
127
|
it 'still creates evidence record' do
|
|
127
|
-
|
|
128
|
+
import_rows
|
|
128
129
|
|
|
129
130
|
issue = Issue.first
|
|
130
131
|
expect(issue.fields).to eq({ 'Title' => 'SQL Injection', 'plugin' => 'csv', 'plugin_id' => '1' })
|
|
@@ -137,4 +138,86 @@ RSpec.describe Dradis::Plugins::CSV::Importer do
|
|
|
137
138
|
end
|
|
138
139
|
end
|
|
139
140
|
end
|
|
141
|
+
|
|
142
|
+
describe '#import' do
|
|
143
|
+
context 'when the project has no RTP' do
|
|
144
|
+
it 'does not import anything' do
|
|
145
|
+
expect(instance.import(file: file)).to eq(false)
|
|
146
|
+
expect(Issue.count).to eq(0)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
context 'when the project has RTP but no saved mapping' do
|
|
151
|
+
before do
|
|
152
|
+
project.update(report_template_properties: create(:report_template_properties))
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it 'does not import anything' do
|
|
156
|
+
expect(instance.import(file: file)).to eq(false)
|
|
157
|
+
expect(Issue.count).to eq(0)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
context 'when a saved mapping exists for these headers' do
|
|
162
|
+
let(:rtp) { create(:report_template_properties) }
|
|
163
|
+
|
|
164
|
+
before do
|
|
165
|
+
project.update(report_template_properties: rtp)
|
|
166
|
+
|
|
167
|
+
issue_mapping = Mapping.create!(
|
|
168
|
+
component: 'csv',
|
|
169
|
+
source: Dradis::Plugins::CSV.mapping_source(headers: headers, entity: :issue),
|
|
170
|
+
destination: rtp.as_mapping_destination
|
|
171
|
+
)
|
|
172
|
+
issue_mapping.mapping_fields.create!(
|
|
173
|
+
source_field: 'Id', destination_field: 'plugin_id', content: '{{ csv[Id] }}'
|
|
174
|
+
)
|
|
175
|
+
issue_mapping.mapping_fields.create!(
|
|
176
|
+
source_field: 'Title', destination_field: 'Title', content: '{{ csv[Title] }}'
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
evidence_mapping = Mapping.create!(
|
|
180
|
+
component: 'csv',
|
|
181
|
+
source: Dradis::Plugins::CSV.mapping_source(headers: headers, entity: :evidence),
|
|
182
|
+
destination: rtp.as_mapping_destination
|
|
183
|
+
)
|
|
184
|
+
evidence_mapping.mapping_fields.create!(
|
|
185
|
+
source_field: 'Host', destination_field: 'node_label', content: '{{ csv[Host] }}'
|
|
186
|
+
)
|
|
187
|
+
evidence_mapping.mapping_fields.create!(
|
|
188
|
+
source_field: 'Location', destination_field: 'Location', content: '{{ csv[Location] }}'
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it 'imports using the saved mapping' do
|
|
193
|
+
expect(instance.import(file: file)).to eq(true)
|
|
194
|
+
|
|
195
|
+
issue = Issue.last
|
|
196
|
+
expect(issue.fields).to include('Title' => 'SQL Injection', 'plugin_id' => '1')
|
|
197
|
+
|
|
198
|
+
node = issue.affected.first
|
|
199
|
+
expect(node.label).to eq('10.0.0.1')
|
|
200
|
+
|
|
201
|
+
evidence = node.evidence.first
|
|
202
|
+
expect(evidence.fields).to include('Location' => '10.0.0.1')
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
describe '.templates' do
|
|
208
|
+
let(:rtp) { create(:report_template_properties) }
|
|
209
|
+
let(:issue_source) { Dradis::Plugins::CSV.mapping_source(headers: headers, entity: :issue) }
|
|
210
|
+
let(:evidence_source) { Dradis::Plugins::CSV.mapping_source(headers: headers, entity: :evidence) }
|
|
211
|
+
|
|
212
|
+
before do
|
|
213
|
+
Mapping.create!(component: 'csv', source: issue_source, destination: rtp.as_mapping_destination)
|
|
214
|
+
Mapping.create!(component: 'csv', source: evidence_source, destination: rtp.as_mapping_destination)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
it 'groups the currently known sources by entity' do
|
|
218
|
+
expect(described_class.templates).to eq(
|
|
219
|
+
issue: [issue_source], evidence: [evidence_source]
|
|
220
|
+
)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
140
223
|
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
require 'rails_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe Dradis::Plugins::CSV::MappingForm do
|
|
4
|
+
let(:headers) { ['Id', 'Title', 'Host', 'Vulnerability Category'] }
|
|
5
|
+
let(:destination) { 'rtp_1' }
|
|
6
|
+
let(:rtp_fields) { { issue: ['Title'], evidence: ['Rating'] } }
|
|
7
|
+
let(:column_mappings) do
|
|
8
|
+
{
|
|
9
|
+
'0' => { 'type' => 'identifier' },
|
|
10
|
+
'1' => { 'type' => 'issue', 'field' => 'Title' },
|
|
11
|
+
'2' => { 'type' => 'node', 'field' => '' },
|
|
12
|
+
'3' => { 'type' => 'evidence', 'field' => 'Rating' }
|
|
13
|
+
}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
let(:issue_source) do
|
|
17
|
+
Dradis::Plugins::CSV.mapping_source(headers: headers, entity: :issue)
|
|
18
|
+
end
|
|
19
|
+
let(:evidence_source) do
|
|
20
|
+
Dradis::Plugins::CSV.mapping_source(headers: headers, entity: :evidence)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
subject(:form) do
|
|
24
|
+
described_class.new(destination: destination, headers: headers, rtp_fields: rtp_fields)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
let(:save) { form.save(column_mappings: column_mappings) }
|
|
28
|
+
|
|
29
|
+
describe '#save' do
|
|
30
|
+
it 'creates one mapping per entity with normalized source fields' do
|
|
31
|
+
expect { save }.to change { Mapping.count }.by(2)
|
|
32
|
+
|
|
33
|
+
issue_mapping = Mapping.find_by(
|
|
34
|
+
component: 'csv', source: issue_source, destination: destination
|
|
35
|
+
)
|
|
36
|
+
expect(issue_mapping.mapping_fields.pluck(:destination_field, :source_field, :content)).to match_array([
|
|
37
|
+
['plugin_id', 'Id', '{{ csv[Id] }}'],
|
|
38
|
+
['Title', 'Title', '{{ csv[Title] }}']
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
evidence_mapping = Mapping.find_by(
|
|
42
|
+
component: 'csv', source: evidence_source, destination: destination
|
|
43
|
+
)
|
|
44
|
+
expect(evidence_mapping.mapping_fields.pluck(:destination_field, :source_field, :content)).to match_array([
|
|
45
|
+
['node_label', 'Host', '{{ csv[Host] }}'],
|
|
46
|
+
['Rating', 'VulnerabilityCategory', '{{ csv[VulnerabilityCategory] }}']
|
|
47
|
+
])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
context 'without a destination' do
|
|
51
|
+
let(:destination) { nil }
|
|
52
|
+
|
|
53
|
+
it 'does not create any mappings' do
|
|
54
|
+
expect { save }.not_to change { Mapping.count }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
context 'when only skipped or blank columns are assigned to an entity' do
|
|
59
|
+
let(:column_mappings) do
|
|
60
|
+
{
|
|
61
|
+
'0' => { 'type' => 'identifier' },
|
|
62
|
+
'1' => { 'type' => 'issue', 'field' => '' },
|
|
63
|
+
'3' => { 'type' => 'skip' }
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'only stores the fields with a destination' do
|
|
68
|
+
expect { save }.to change { Mapping.count }.by(1)
|
|
69
|
+
|
|
70
|
+
issue_mapping = Mapping.find_by(
|
|
71
|
+
component: 'csv', source: issue_source, destination: destination
|
|
72
|
+
)
|
|
73
|
+
expect(issue_mapping.mapping_fields.pluck(:destination_field)).to eq(['plugin_id'])
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
context 'when the RTP has no fields defined for an entity' do
|
|
78
|
+
let(:rtp_fields) { { issue: ['Title'], evidence: [] } }
|
|
79
|
+
|
|
80
|
+
it 'does not create a mapping for that entity, even for its identifier/node column' do
|
|
81
|
+
expect { save }.to change { Mapping.count }.by(1)
|
|
82
|
+
|
|
83
|
+
expect(Mapping.find_by(source: evidence_source)).to be_nil
|
|
84
|
+
|
|
85
|
+
issue_mapping = Mapping.find_by(
|
|
86
|
+
component: 'csv', source: issue_source, destination: destination
|
|
87
|
+
)
|
|
88
|
+
expect(issue_mapping.mapping_fields.pluck(:destination_field)).to match_array(['plugin_id', 'Title'])
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
context 'when the RTP has no fields defined for either entity' do
|
|
93
|
+
let(:rtp_fields) { { issue: [], evidence: [] } }
|
|
94
|
+
|
|
95
|
+
it 'does not create any mappings' do
|
|
96
|
+
expect { save }.not_to change { Mapping.count }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
context 'when a column is assigned to a custom field' do
|
|
101
|
+
let(:column_mappings) do
|
|
102
|
+
{
|
|
103
|
+
'0' => { 'type' => 'identifier' },
|
|
104
|
+
'1' => { 'type' => 'issue', 'field' => 'Custom Field', 'custom_field' => 'CVSS Score' },
|
|
105
|
+
'2' => { 'type' => 'node', 'field' => '' },
|
|
106
|
+
'3' => { 'type' => 'evidence', 'field' => 'Rating' }
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it 'stores the typed-in name as the destination field' do
|
|
111
|
+
save
|
|
112
|
+
|
|
113
|
+
issue_mapping = Mapping.find_by(
|
|
114
|
+
component: 'csv', source: issue_source, destination: destination
|
|
115
|
+
)
|
|
116
|
+
expect(issue_mapping.mapping_fields.pluck(:destination_field)).to match_array(['plugin_id', 'CVSS Score'])
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
context 'when no custom field name is provided' do
|
|
120
|
+
let(:column_mappings) do
|
|
121
|
+
{
|
|
122
|
+
'0' => { 'type' => 'identifier' },
|
|
123
|
+
'1' => { 'type' => 'issue', 'field' => 'Custom Field', 'custom_field' => '' },
|
|
124
|
+
'3' => { 'type' => 'skip' }
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it 'does not store a field for that column' do
|
|
129
|
+
save
|
|
130
|
+
|
|
131
|
+
issue_mapping = Mapping.find_by(
|
|
132
|
+
component: 'csv', source: issue_source, destination: destination
|
|
133
|
+
)
|
|
134
|
+
expect(issue_mapping.mapping_fields.pluck(:destination_field)).to eq(['plugin_id'])
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: dradis-csv
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 5.
|
|
4
|
+
version: 5.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniel Martin
|
|
@@ -79,8 +79,11 @@ files:
|
|
|
79
79
|
- lib/dradis-csv.rb
|
|
80
80
|
- lib/dradis/plugins/csv.rb
|
|
81
81
|
- lib/dradis/plugins/csv/engine.rb
|
|
82
|
+
- lib/dradis/plugins/csv/field_processor.rb
|
|
82
83
|
- lib/dradis/plugins/csv/gem_version.rb
|
|
83
84
|
- lib/dradis/plugins/csv/importer.rb
|
|
85
|
+
- lib/dradis/plugins/csv/mapping.rb
|
|
86
|
+
- lib/dradis/plugins/csv/mapping_form.rb
|
|
84
87
|
- lib/dradis/plugins/csv/version.rb
|
|
85
88
|
- spec/features/upload_spec.rb
|
|
86
89
|
- spec/fixtures/files/simple (copy).csv
|
|
@@ -88,6 +91,7 @@ files:
|
|
|
88
91
|
- spec/fixtures/files/simple_malformed.csv
|
|
89
92
|
- spec/jobs/dradis/plugins/csv/mapping_import_job_spec.rb
|
|
90
93
|
- spec/lib/dradis/plugins/csv/importer_spec.rb
|
|
94
|
+
- spec/lib/dradis/plugins/csv/mapping_form_spec.rb
|
|
91
95
|
homepage: http://dradis.com
|
|
92
96
|
licenses:
|
|
93
97
|
- GPL-2
|
|
@@ -116,3 +120,4 @@ test_files:
|
|
|
116
120
|
- spec/fixtures/files/simple_malformed.csv
|
|
117
121
|
- spec/jobs/dradis/plugins/csv/mapping_import_job_spec.rb
|
|
118
122
|
- spec/lib/dradis/plugins/csv/importer_spec.rb
|
|
123
|
+
- spec/lib/dradis/plugins/csv/mapping_form_spec.rb
|