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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8bad2356d2ad2625aa0180faf0ab79f8a8acf40ce59afcdcd7843e1df6eaf358
4
- data.tar.gz: 00bf582bd667765e48311382f06b4f10587cfbe59424802dca5c96a1b9237b8d
3
+ metadata.gz: 9fc992eccc61f6f4f2b7a0d2696dbda946add4cb50a38cb92cc435dea0dc1038
4
+ data.tar.gz: 993fdee3bc19179b7f42801f6e0a078fa6e26d0e8eec09ae60a4e89006627e09
5
5
  SHA512:
6
- metadata.gz: 768891ef2bdf14268a94b4607300f8aca8ded2020335b51077efc74d0e5dad2aa36709968a2f7e09119519599c57e34610f4b4034ee0fa69a9462ad547fe9fab
7
- data.tar.gz: 0021f7ee9aa94f498a2c469b4c11f3da43b85476c7bf4018e3970fa2252f93a6d02c5bc60163c81baaf802da67d39b3f3ed6bac69556587e834d19c326bfcd4e
6
+ metadata.gz: 7f3322fbe5e012b8bbe5eaab98450f71c3273461398f5e1d5e814c626152037216288fafe53614305789dd8a415c5ac5071bc6ed1b0ae01ee8268bd0f71964c1
7
+ data.tar.gz: be698324c4a89f500d9da324016e149b8d9d09842f5af2ae26a922130eb2fa21aab85d9b4baa6d76f3b3254d7ba28acb343d56a576d07dac3dc73492a87acf45
data/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ v5.3.0 (August 2026)
2
+ - Add support for managing Mappings through the Mappings Manager
3
+ - Add support for saving column assignments from the upload mapper
4
+
1
5
  v5.2.0 (June 2026)
2
6
  - No changes
3
7
 
@@ -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
- @default_columns = ['Column Header', 'Entity', 'Dradis Field']
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
- importer.import_csv(file: file, mappings: mappings)
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
@@ -8,7 +8,7 @@ module Dradis
8
8
 
9
9
  module VERSION
10
10
  MAJOR = 5
11
- MINOR = 2
11
+ MINOR = 3
12
12
  TINY = 0
13
13
  PRE = nil
14
14
 
@@ -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
- def import(params={})
8
- logger.info { 'Uploading CSV file...' }
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
- logger.info { 'Done' }
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
- def import_csv(params)
14
- logger.info { 'Worker process starting background task.' }
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
- mappings_groups = params[:mappings].group_by { |index, mapping| mapping['type'] }
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
- filename = File.basename(params[:file])
19
- id_index = Integer(mappings_groups['identifier']&.first&.first, exception: false)
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(params[:file], headers: true).with_index do |row, index|
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 :evidence_mappings, :issue_lookup, :issue_mappings, :node_index
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: @evidence_mappings, row: row)
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
@@ -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#import_csv' do
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(:import_csv).and_return(true)
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(:import_csv) do
17
- instance.import_csv(file: file, mappings: mappings)
17
+ let(:import_rows) do
18
+ instance.import_rows(file: file, headers: headers, mappings: mappings)
18
19
  end
19
20
 
20
- describe '#import_csv' do
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
- import_csv
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
- import_csv
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
- import_csv
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
- import_csv
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
- import_csv
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
- import_csv
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.2.0
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