Class: HeimdallTools::ASFFMapper

Inherits:
Object
  • Object
show all
Defined in:
lib/heimdall_tools/asff_mapper.rb

Overview

TODO: use hash.dig and safe navigation operator throughout

Direct Known Subclasses

ProwlerMapper

Constant Summary collapse

IMPACT_MAPPING =
{
  CRITICAL: 0.9,
  HIGH: 0.7,
  MEDIUM: 0.5,
  LOW: 0.3,
  INFORMATIONAL: 0.0
}.freeze
PRODUCT_ARN_MAPPING =
{
  %r{arn:.+:securityhub:.+:.*:product/aws/firewall-manager} => FirewallManager,
  %r{arn:.+:securityhub:.+:.*:product/aws/securityhub} => SecurityHub,
  %r{arn:.+:securityhub:.+:.*:product/prowler/prowler} => Prowler
}.freeze

Instance Method Summary collapse

Constructor Details

#initialize(asff_json, securityhub_standards_json_array: nil, meta: nil) ⇒ ASFFMapper

Returns a new instance of ASFFMapper.



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/heimdall_tools/asff_mapper.rb', line 50

def initialize(asff_json, securityhub_standards_json_array: nil, meta: nil)
  @meta = meta

  @supporting_docs = {}
  @supporting_docs[SecurityHub] = SecurityHub.supporting_docs({ standards: securityhub_standards_json_array })

  begin
    asff_required_keys = %w{AwsAccountId CreatedAt Description GeneratorId Id ProductArn Resources SchemaVersion Severity Title Types UpdatedAt}
    @report = JSON.parse(asff_json)
    if @report.length == 1 && @report.member?('Findings') && @report['Findings'].each { |finding| asff_required_keys.to_set.difference(finding.keys.to_set).none? }.all?
      # ideal case that is spec compliant
      # might need to ensure that the file is utf-8 encoded and remove a BOM if one exists
    elsif asff_required_keys.to_set.difference(@report.keys.to_set).none?
      # individual finding so have to add wrapping array
      @report = { 'Findings' => [@report] }
    else
      raise 'Not a findings file nor an individual finding'
    end
  rescue StandardError => e
    raise "Invalid ASFF file provided:\nException: #{e}"
  end

  @coder = HTMLEntities.new
end

Instance Method Details

#desc_tags(data, label) ⇒ Object



108
109
110
# File 'lib/heimdall_tools/asff_mapper.rb', line 108

def desc_tags(data, label)
  { data: data || NA_STRING, label: label || NA_STRING }
end

#encode(string) ⇒ Object



75
76
77
# File 'lib/heimdall_tools/asff_mapper.rb', line 75

def encode(string)
  @coder.encode(string, :basic, :named, :decimal)
end

#external_product_handler(product, data, func, default) ⇒ Object



79
80
81
82
83
84
85
86
87
88
89
# File 'lib/heimdall_tools/asff_mapper.rb', line 79

def external_product_handler(product, data, func, default)
  if (product.is_a?(Regexp) || (arn = PRODUCT_ARN_MAPPING.keys.find { |a| product.match(a) })) && PRODUCT_ARN_MAPPING.key?(arn || product) && PRODUCT_ARN_MAPPING[arn || product].respond_to?(func)
    keywords = { encode: method(:encode) }
    keywords = keywords.merge(@supporting_docs[PRODUCT_ARN_MAPPING[arn || product]]) if @supporting_docs.member?(PRODUCT_ARN_MAPPING[arn || product])
    PRODUCT_ARN_MAPPING[arn || product].send(func, data, **keywords)
  elsif default.is_a? Proc
    default.call
  else
    default
  end
end

#impact(finding) ⇒ Object



96
97
98
99
100
101
102
103
104
105
106
# File 'lib/heimdall_tools/asff_mapper.rb', line 96

def impact(finding)
  # there can be findings listed that are intentionally ignored due to the underlying control being superceded by a control from a different standard
  if finding.member?('Workflow') && finding['Workflow'].member?('Status') && finding['Workflow']['Status'] == 'SUPPRESSED'
    imp = :INFORMATIONAL
  else
    # severity is required, but can be either 'label' or 'normalized' internally with 'label' being preferred.  other values can be in here too such as the original severity rating.
    default = proc { finding['Severity'].key?('Label') ? finding['Severity']['Label'].to_sym : finding['Severity']['Normalized']/100.0 }
    imp = external_product_handler(finding['ProductArn'], finding, :finding_impact, default)
  end
  imp.is_a?(Symbol) ? IMPACT_MAPPING[imp] : imp
end

#nist_tag(finding) ⇒ Object



91
92
93
94
# File 'lib/heimdall_tools/asff_mapper.rb', line 91

def nist_tag(finding)
  tags = external_product_handler(finding['ProductArn'], finding, :finding_nist_tag, {})
  tags.empty? ? DEFAULT_NIST_TAG : tags
end

#subfindings(finding) ⇒ Object



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/heimdall_tools/asff_mapper.rb', line 112

def subfindings(finding)
  subfinding = {}

  statusreason = finding['Compliance']['StatusReasons'].map { |reason| reason.flatten.map { |string| encode(string) } }.flatten.join("\n") if finding.key?('Compliance') && finding['Compliance'].key?('StatusReasons')
  if finding.key?('Compliance') && finding['Compliance'].key?('Status')
    case finding['Compliance']['Status']
    when 'PASSED'
      subfinding['status'] = 'passed'
      subfinding['message'] = statusreason if statusreason
    when 'WARNING'
      subfinding['status'] = 'skipped'
      subfinding['skip_message'] = statusreason if statusreason
    when 'FAILED'
      subfinding['status'] = 'failed'
      subfinding['message'] = statusreason if statusreason
    when 'NOT_AVAILABLE'
      # primary meaning is that the check could not be performed due to a service outage or API error, but it's also overloaded to mean NOT_APPLICABLE so technically 'skipped' or 'error' could be applicable, but AWS seems to do the equivalent of skipped
      subfinding['status'] = 'skipped'
      subfinding['skip_message'] = statusreason if statusreason
    else
      subfinding['status'] = 'error' # not a valid value for the status enum
      subfinding['message'] = statusreason if statusreason
    end
  else
    subfinding['status'] = 'skipped' # if no compliance status is provided which is a weird but possible case, then skip
    subfinding['skip_message'] = statusreason if statusreason
  end

  subfinding['code_desc'] = external_product_handler(finding['ProductArn'], finding, :subfindings_code_desc, '')
  subfinding['code_desc'] += '; ' unless subfinding['code_desc'].empty?
  subfinding['code_desc'] += "Resources: [#{finding['Resources'].map { |r| "Type: #{encode(r['Type'])}, Id: #{encode(r['Id'])}#{", Partition: #{encode(r['Partition'])}" if r.key?('Partition')}#{", Region: #{encode(r['Region'])}" if r.key?('Region')}" }.join(', ')}]"

  subfinding['start_time'] = finding.key?('LastObservedAt') ? finding['LastObservedAt'] : finding['UpdatedAt']

  [subfinding]
end

#to_hdfObject



149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
# File 'lib/heimdall_tools/asff_mapper.rb', line 149

def to_hdf
  product_groups = {}
  @report['Findings'].each do |finding|
    printf("\rProcessing: %s", $spinner.next)

    external = method(:external_product_handler).curry(4)[finding['ProductArn']][finding]

    # group subfindings by asff productarn and then hdf id
    item = {}
    item['id'] = external[:finding_id][encode(finding['GeneratorId'])]

    item['title'] = external[:finding_title][encode(finding['Title'])]

    item['tags'] = { nist: nist_tag(finding) }

    item['impact'] = impact(finding)

    item['desc'] = encode(finding['Description'])

    item['descriptions'] = []
    item['descriptions'] << desc_tags(finding['Remediation']['Recommendation'].map { |_k, v| encode(v) }.join("\n"), 'fix') if finding.key?('Remediation') && finding['Remediation'].key?('Recommendation')

    item['refs'] = []
    item['refs'] << { url: finding['SourceUrl'] } if finding.key?('SourceUrl')

    item['source_location'] = NA_HASH

    item['results'] = subfindings(finding)

    arn = PRODUCT_ARN_MAPPING.keys.find { |a| finding['ProductArn'].match(a) }
    if arn.nil?
      product_info = finding['ProductArn'].split(':')[-1]
      arn = Regexp.new "arn:.+:securityhub:.+:.*:product/#{product_info.split('/')[1]}/#{product_info.split('/')[2]}"
    end
    product_groups[arn] = {} if product_groups[arn].nil?
    product_groups[arn][item['id']] = [] if product_groups[arn][item['id']].nil?
    product_groups[arn][item['id']] << [item, finding]
  end

  controls = []
  product_groups.each do |product, id_groups|
    id_groups.each do |id, data|
      printf("\rProcessing: %s", $spinner.next)

      external = method(:external_product_handler).curry(4)[product]

      group = data.map { |d| d[0] }
      findings = data.map { |d| d[1] }

      product_info = findings[0]['ProductArn'].split(':')[-1].split('/')
      product_name = external[findings][:product_name][encode("#{product_info[1]}/#{product_info[2]}")]

      item = {}
      # add product name to id if any ids are the same across products
      item['id'] = product_groups.reject { |pg| pg == product }.values.any? { |ig| ig.keys.include?(id) } ? "[#{product_name}] #{id}" : id

      item['title'] = "#{product_name}: #{group.map { |d| d['title'] }.uniq.join(';')}"

      item['tags'] = { nist: group.map { |d| d['tags'][:nist] }.flatten.uniq }

      item['impact'] = group.map { |d| d['impact'] }.max

      item['desc'] = external[group][:desc][group.map { |d| d['desc'] }.uniq.join("\n")]

      item['descriptions'] = group.map { |d| d['descriptions'] }.flatten.compact.reject(&:empty?).uniq

      item['refs'] = group.map { |d| d['refs'] }.flatten.compact.reject(&:empty?).uniq

      item['source_location'] = NA_HASH
      item['code'] = JSON.pretty_generate({ Findings: findings })

      item['results'] = group.map { |d| d['results'] }.flatten.uniq

      controls << item
    end
  end

  results = HeimdallDataFormat.new(profile_name: @meta&.key?('name') ? @meta['name'] : 'AWS Security Finding Format',
                                   title: @meta&.key?('title') ? @meta['title'] : 'ASFF findings',
                                   controls: controls)
  results.to_hdf
end