shield_ast 1.0.0 → 1.2.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: baa8585d940103a2ffa3e2b7ccd47166dd75d3caebd58b16031d814c1f9a9af1
4
- data.tar.gz: 794fb75bf613ea453cc5628cddb7d37d43751f43d215619053958bec757069bf
3
+ metadata.gz: 7f6c61da40db55d33cb491cfac9aee8d6a42160b8703db338498d8990852245c
4
+ data.tar.gz: a72f17d19e7b92057db035e00783c51d2007e874ec216f3b3301d1b9c640f349
5
5
  SHA512:
6
- metadata.gz: b9ca658a73fc85de6ef32f09a4525e8945e558ea52a00362b714ce26dc22162f81ecabd5684bf6e9aea92a51b70f6a0b1bcbad035d977d0eb467940a7339f7b8
7
- data.tar.gz: fa30e723c35d87b199e79d7fad982dcb47ac868b04133e85216d6eb3023e902f9a6c2648cef2653118b58ff7b94ff84c55b05692030907a90b92ca58c1e1ae98
6
+ metadata.gz: f2cb6e62fa8dd5f7bf41f8411af8f4420179ba9b356413bb204eddc26a7213704d37a8534d4dcec5e6a62ed12b439dada3b1551ea5802eae12975fce8c84321c
7
+ data.tar.gz: 2dca66c6eac359384237bf73e57621f911f8bea31d4b6a481325e5ab7f7011a7656114949908df4c026c060b3dedf6c025f0f2114b81d5cce4f5d0d86806a5d2
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prawn"
4
+ require "prawn/table"
5
+
6
+ Prawn::Document.generate(output_file) do
7
+ def extract_short_description(result)
8
+ message = result[:extra]&.[](:message) || result["extra"]&.[]("message") || result.dig(:extra,
9
+ :message) || result.dig(
10
+ "extra", "message"
11
+ ) || "No description available"
12
+ description = message.gsub("\n", " ").strip
13
+ description.length > 80 ? "#{description[0..80]}..." : description
14
+ end
15
+
16
+ font "Helvetica"
17
+ font_size 12
18
+
19
+ text "Shield AST Scan Report", size: 20, style: :bold, align: :center
20
+ move_down 10
21
+ text "Generated by Shield AST v#{version}", size: 10, align: :center
22
+ text generated_at, size: 10, align: :center
23
+ move_down 20
24
+
25
+ text "Summary", size: 16, style: :bold
26
+ move_down 10
27
+ text "Generated on: #{generated_at}"
28
+ text "Scan Duration: #{scan_duration}"
29
+ text "Total Issues Found: #{total_issues}"
30
+ text "Severity Breakdown:", style: :bold
31
+ text "Errors: #{severity_summary[:error_count]} (High Severity)", color: "DC3545" # Red
32
+ text "Warnings: #{severity_summary[:warning_count]} (Medium Severity)", color: "FFC107" # Yellow
33
+ text "Info: #{severity_summary[:info_count]} (Low Severity)", color: "17A2B8" # Blue
34
+ move_down 20
35
+
36
+ if sast_results.any?
37
+ text "Static Application Security Testing (SAST)", size: 16, style: :bold
38
+ text "Issues Found: #{sast_results.length}", size: 12
39
+ move_down 10
40
+ table_data = [%w[Severity Title File Description]]
41
+ sast_results.each do |result|
42
+ severity = result[:severity] || result["severity"] || result.dig(:extra,
43
+ :severity) || result.dig("extra",
44
+ "severity") || "INFO"
45
+ message = result.dig(:extra, :message) || result.dig("extra", "message") || "Unknown issue"
46
+ title = message.split(".")[0].strip
47
+ file_info = if result[:path] || result["path"]
48
+ "#{File.basename(result[:path] || result["path"])}:#{result.dig(
49
+ :start, :line
50
+ ) || result.dig("start", "line") || "N/A"}"
51
+ else
52
+ "N/A"
53
+ end
54
+ description = extract_short_description(result)
55
+ severity_color = case severity.upcase
56
+ when "ERROR" then "DC3545"
57
+ when "WARNING" then "FFC107"
58
+ else "17A2B8"
59
+ end
60
+ table_data << [{ content: severity, text_color: severity_color }, title, file_info, description]
61
+ rescue StandardError => e
62
+ table_data << [{ content: "ERROR", text_color: "DC3545" }, "Invalid result", "N/A", "Error: #{e.message}"]
63
+ end
64
+ table table_data, header: true, width: bounds.width, cell_style: { size: 10 } do
65
+ cells.padding = 5
66
+ cells.borders = %i[top bottom left right]
67
+ row(0).font_style = :bold
68
+ row(0).background_color = "007BFF"
69
+ row(0).text_color = "FFFFFF"
70
+ end
71
+ move_down 20
72
+ end
73
+
74
+ if sca_results.any?
75
+ text "Software Composition Analysis (SCA)", size: 16, style: :bold
76
+ text "Issues Found: #{sca_results.length}", size: 12
77
+ move_down 10
78
+ table_data = [["Severity", "Title", "File", "Vuln Version", "Fixed Version", "Description"]]
79
+ sca_results.each_with_index do |result, _index|
80
+ severity = result[:severity] || result["severity"] || "INFO"
81
+ title = (result[:title] || result["title"] || "Unknown issue").to_s
82
+ file = (result[:file] || result["file"] || "N/A").to_s
83
+ vuln_version = (result[:vulnerable_version] || result["vulnerable_version"] || "N/A").to_s
84
+ fixed_version = (result[:fixed_version] || result["fixed_version"] || "N/A").to_s
85
+ description = (result[:description] || result["description"] || "No description").to_s
86
+ description = description[0..80] + (description.length > 80 ? "..." : "")
87
+ severity_color = case severity.upcase
88
+ when "ERROR" then "DC3545"
89
+ when "WARNING" then "FFC107"
90
+ else "17A2B8"
91
+ end
92
+ table_data << [{ content: severity, text_color: severity_color }, title, file, vuln_version, fixed_version,
93
+ description]
94
+ rescue StandardError => e
95
+ table_data << [{ content: "ERROR", text_color: "DC3545" }, "Invalid result", "N/A", "N/A", "N/A",
96
+ "Error: #{e.message}"]
97
+ end
98
+ table table_data, header: true, width: bounds.width, cell_style: { size: 10 } do
99
+ cells.padding = 5
100
+ cells.borders = %i[top bottom left right]
101
+ row(0).font_style = :bold
102
+ row(0).background_color = "007BFF"
103
+ row(0).text_color = "FFFFFF"
104
+ end
105
+ move_down 20
106
+ end
107
+
108
+ if iac_results.any?
109
+ text "Infrastructure as Code (IaC)", size: 16, style: :bold
110
+ text "Issues Found: #{iac_results.length}", size: 12
111
+ move_down 10
112
+ table_data = [%w[Severity Title File Description]]
113
+ iac_results.each_with_index do |result, _index|
114
+ severity = result[:severity] || result["severity"] || result.dig(:extra,
115
+ :severity) || result.dig("extra",
116
+ "severity") || "INFO"
117
+ message = result.dig(:extra, :message) || result.dig("extra", "message") || "Unknown issue"
118
+ title = message.split(".")[0].strip
119
+ file_info = if result[:path] || result["path"]
120
+ "#{File.basename(result[:path] || result["path"])}:#{result.dig(
121
+ :start, :line
122
+ ) || result.dig("start", "line") || "N/A"}"
123
+ else
124
+ "N/A"
125
+ end
126
+ description = extract_short_description(result)
127
+ severity_color = case severity.upcase
128
+ when "ERROR" then "DC3545"
129
+ when "WARNING" then "FFC107"
130
+ else "17A2B8"
131
+ end
132
+ table_data << [{ content: severity, text_color: severity_color }, title, file_info, description]
133
+ rescue StandardError => e
134
+ table_data << [{ content: "ERROR", text_color: "DC3545" }, "Invalid result", "N/A", "Error: #{e.message}"]
135
+ end
136
+ table table_data, header: true, width: bounds.width, cell_style: { size: 10 } do
137
+ cells.padding = 5
138
+ cells.borders = %i[top bottom left right]
139
+ row(0).font_style = :bold
140
+ row(0).background_color = "007BFF"
141
+ row(0).text_color = "FFFFFF"
142
+ end
143
+ end
144
+
145
+ move_down 20
146
+ text "Generated by Shield AST v#{version}", size: 10, align: :center
147
+ end
@@ -25,25 +25,15 @@ module ShieldAst
25
25
  puts "Exit code: #{exit_code}" if ENV["DEBUG"]
26
26
  puts "Output: #{output}" if ENV["DEBUG"]
27
27
 
28
- # OSV Scanner exit codes:
29
- # 0: No vulnerabilities found
30
- # 1: Vulnerabilities found
31
- # 1-126: Vulnerability result related errors
32
- # 127: General error
33
- # 128: No packages found
34
- # 129-255: Non result related errors
35
-
36
28
  case exit_code
37
29
  when 0
38
- { "results" => [] } # No vulnerabilities
30
+ { "results" => [] }
39
31
  when 1
40
- { "results" => parse_json_output(output) } # Vulnerabilities found
32
+ { "results" => parse_json_output(output) }
41
33
  when 1..126
42
- # Vulnerability related errors, but try to parse results anyway
43
34
  puts "OSV Scanner vulnerability error (exit code: #{exit_code})" if ENV["DEBUG"]
44
35
  { "results" => parse_json_output(output) }
45
36
  when 127
46
- # General error, but if we have JSON output, use it
47
37
  if output.include?('{"results"')
48
38
  puts "OSV Scanner completed with general error but has results" if ENV["DEBUG"]
49
39
  { "results" => parse_json_output(output) }
@@ -111,7 +101,6 @@ module ShieldAst
111
101
  severity = determine_severity(vuln, package_data)
112
102
  file_path = determine_file_path(ecosystem)
113
103
 
114
- # Extract fixed version info
115
104
  fixed_version = extract_fixed_version(vuln)
116
105
 
117
106
  {
@@ -142,7 +131,6 @@ module ShieldAst
142
131
  end
143
132
 
144
133
  def self.extract_fixed_version(vuln)
145
- # Try to find fixed version in affected ranges
146
134
  if vuln["affected"] && vuln["affected"].is_a?(Array)
147
135
  vuln["affected"].each do |affected|
148
136
  if affected["ranges"] && affected["ranges"].is_a?(Array)
@@ -155,14 +143,12 @@ module ShieldAst
155
143
  end
156
144
  end
157
145
 
158
- # Also check database_specific for fixed version
159
146
  if affected["database_specific"] && affected["database_specific"]["last_affected"]
160
- return ">" + affected["database_specific"]["last_affected"]
147
+ return "> #{affected["database_specific"]["last_affected"]}"
161
148
  end
162
149
  end
163
150
  end
164
151
 
165
- # Fallback: check database_specific at root level
166
152
  if vuln["database_specific"]
167
153
  return vuln["database_specific"]["fixed_version"] if vuln["database_specific"]["fixed_version"]
168
154
  end
@@ -171,17 +157,13 @@ module ShieldAst
171
157
  end
172
158
 
173
159
  def self.determine_severity(vuln, package_data)
174
- # Check database_specific severity first
175
- if vuln.dig("database_specific", "severity")
176
- return map_severity(vuln["database_specific"]["severity"])
177
- end
160
+ return map_severity(vuln["database_specific"]["severity"]) if vuln.dig("database_specific", "severity")
178
161
 
179
- # Check groups max_severity
180
162
  groups = package_data&.dig("groups") || []
181
163
  max_severity = groups.first&.dig("max_severity")
182
164
  return cvss_to_severity(max_severity.to_f) if max_severity
183
165
 
184
- "WARNING" # Default
166
+ "WARNING" # Default severity
185
167
  end
186
168
 
187
169
  def self.determine_file_path(ecosystem)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ShieldAst
4
- VERSION = "1.0.0"
4
+ VERSION = "1.2.0"
5
5
  end
data/lib/shield_ast.rb CHANGED
@@ -1,23 +1,31 @@
1
+ # lib/shield_ast/main.rb
1
2
  # frozen_string_literal: true
2
3
 
3
4
  require_relative "shield_ast/version"
4
5
  require_relative "shield_ast/runner"
5
-
6
6
  require "json"
7
+ require "fileutils"
8
+ require "erb"
9
+ require "prawn"
10
+ require "prawn/table"
7
11
 
8
12
  # Main module for the Shield AST gem.
9
13
  module ShieldAst
10
14
  class Error < StandardError; end
11
15
 
12
16
  # Main class for the Shield AST command-line tool.
13
- # Handles command-line argument parsing and delegates to the Runner.
14
17
  class Main
18
+ SCAN_DATA_FILE = File.join(Dir.pwd, "lib", "reports", "scan_data.json")
19
+ REPORT_JSON_FILE = File.join(Dir.pwd, "lib", "reports", "scan_report.json")
20
+ REPORT_PDF_FILE = File.join(Dir.pwd, "lib", "reports", "scan_report.pdf")
21
+ PDF_TEMPLATE = File.join(__dir__, "reports", "templates", "pdf_report_template.rb")
22
+
15
23
  def self.call(args)
16
24
  options = parse_args(args)
17
25
  handle_options(options)
18
26
  end
19
27
 
20
- private_class_method def self.handle_options(options)
28
+ def self.handle_options(options)
21
29
  if options[:help]
22
30
  show_help
23
31
  elsif options[:version]
@@ -25,14 +33,14 @@ module ShieldAst
25
33
  elsif options[:command] == "scan"
26
34
  run_scan(options)
27
35
  elsif options[:command] == "report"
28
- puts "Generating report... (not yet implemented)"
36
+ generate_report(options)
29
37
  else
30
38
  puts "Invalid command. Use 'ast help' for more information."
31
39
  show_help
32
40
  end
33
41
  end
34
42
 
35
- private_class_method def self.run_scan(options)
43
+ def self.run_scan(options)
36
44
  path = options[:path] || Dir.pwd
37
45
  options = apply_default_scanners(options)
38
46
 
@@ -45,9 +53,129 @@ module ShieldAst
45
53
  execution_time = end_time - start_time
46
54
 
47
55
  display_reports(reports, execution_time)
56
+ save_scan_data(reports, execution_time)
57
+ end
58
+
59
+ def self.save_scan_data(reports, execution_time)
60
+ normalized_reports = {}
61
+ reports.each do |key, value|
62
+ normalized_reports[key.to_sym] = value.transform_keys(&:to_sym)
63
+ end
64
+ data = {
65
+ reports: normalized_reports,
66
+ execution_time: execution_time,
67
+ generated_at: Time.now.strftime("%Y-%m-%d %H:%M:%S %z")
68
+ }
69
+ FileUtils.mkdir_p(File.dirname(SCAN_DATA_FILE))
70
+ File.write(SCAN_DATA_FILE, JSON.pretty_generate(data))
71
+ puts "Scan data saved to: #{SCAN_DATA_FILE}"
72
+ end
73
+
74
+ def self.load_scan_data
75
+ unless File.exist?(SCAN_DATA_FILE)
76
+ puts "Error: Scan data file #{SCAN_DATA_FILE} does not exist."
77
+ return nil
78
+ end
79
+
80
+ begin
81
+ JSON.parse(File.read(SCAN_DATA_FILE), symbolize_names: true)
82
+ rescue JSON::ParserError => e
83
+ puts "Error: Invalid scan data in #{SCAN_DATA_FILE}: #{e.message}"
84
+ nil
85
+ end
86
+ end
87
+
88
+ def self.generate_report(options)
89
+ scan_data = load_scan_data
90
+ unless scan_data
91
+ puts "No scan data available. Please run 'ast scan' first."
92
+ return
93
+ end
94
+
95
+ output_format = options[:output] || "json"
96
+ unless %w[json pdf].include?(output_format)
97
+ puts "Error: Invalid output format '#{output_format}'. Use 'json' or 'pdf'."
98
+ return
99
+ end
100
+
101
+ puts "Generating #{output_format.upcase} report..."
102
+
103
+ if output_format == "json"
104
+ generate_json_report(scan_data)
105
+ elsif output_format == "pdf"
106
+ generate_pdf_report(scan_data)
107
+ end
108
+ end
109
+
110
+ def self.generate_json_report(scan_data)
111
+ FileUtils.mkdir_p(File.dirname(REPORT_JSON_FILE))
112
+ report = {
113
+ generated_at: scan_data[:generated_at],
114
+ scan_duration: format_duration(scan_data[:execution_time]),
115
+ total_issues: calculate_total_issues(scan_data[:reports]),
116
+ severity_summary: calculate_severity_summary(scan_data[:reports]),
117
+ reports: scan_data[:reports]
118
+ }
119
+ File.write(REPORT_JSON_FILE, JSON.pretty_generate(report))
120
+ puts "JSON report generated at: #{REPORT_JSON_FILE}"
121
+ end
122
+
123
+ def self.generate_pdf_report(scan_data)
124
+ unless File.exist?(PDF_TEMPLATE)
125
+ puts "Error: PDF template file #{PDF_TEMPLATE} not found."
126
+ return
127
+ end
128
+
129
+ FileUtils.mkdir_p(File.dirname(REPORT_PDF_FILE))
130
+
131
+ version = ShieldAst::VERSION
132
+ generated_at = scan_data[:generated_at]
133
+ scan_duration = format_duration(scan_data[:execution_time])
134
+ sast_results = normalize_results(sort_by_severity(scan_data[:reports][:sast]&.[](:results) || []))
135
+ sca_results = normalize_results(sort_by_severity(scan_data[:reports][:sca]&.[](:results) || []))
136
+ iac_results = normalize_results(sort_by_severity(scan_data[:reports][:iac]&.[](:results) || []))
137
+ total_issues = calculate_total_issues(scan_data[:reports])
138
+ severity_summary = calculate_severity_summary(scan_data[:reports])
139
+ output_file = REPORT_PDF_FILE
140
+
141
+ begin
142
+ template_context = Object.new
143
+ template_context.instance_variable_set(:@version, version)
144
+ template_context.instance_variable_set(:@generated_at, generated_at)
145
+ template_context.instance_variable_set(:@scan_duration, scan_duration)
146
+ template_context.instance_variable_set(:@sast_results, sast_results)
147
+ template_context.instance_variable_set(:@sca_results, sca_results)
148
+ template_context.instance_variable_set(:@iac_results, iac_results)
149
+ template_context.instance_variable_set(:@total_issues, total_issues)
150
+ template_context.instance_variable_set(:@severity_summary, severity_summary)
151
+ template_context.instance_variable_set(:@output_file, output_file)
152
+ template = File.read(PDF_TEMPLATE)
153
+ template_context.instance_eval template, PDF_TEMPLATE
154
+ puts "PDF report generated at: #{REPORT_PDF_FILE}"
155
+ rescue StandardError => e
156
+ puts "Error: Failed to generate PDF: #{e.message}"
157
+ puts "Error: Backtrace: #{e.backtrace.join("\n")}"
158
+ end
48
159
  end
49
160
 
50
- private_class_method def self.apply_default_scanners(options)
161
+ def self.normalize_results(results)
162
+ results.map do |result|
163
+ normalized = result.transform_keys(&:to_sym)
164
+ normalized[:severity] ||= normalized[:extra]&.[](:severity) || normalized[:extra]&.[]("severity") || "INFO"
165
+ normalized[:vulnerable_version] = normalized[:vulnerable_version].to_s if normalized[:vulnerable_version]
166
+ normalized[:fixed_version] = normalized[:fixed_version].to_s if normalized[:fixed_version]
167
+ normalized
168
+ end
169
+ end
170
+
171
+ def self.calculate_total_issues(reports)
172
+ sast_count = (reports[:sast]&.[](:results) || reports["sast"]&.[]("results") || []).length
173
+ sca_count = (reports[:sca]&.[](:results) || reports["sca"]&.[]("results") || []).length
174
+ iac_count = (reports[:iac]&.[](:results) || reports["iac"]&.[]("results") || []).length
175
+ sast_count + sca_count + iac_count
176
+ end
177
+
178
+ def self.apply_default_scanners(options)
51
179
  options.tap do |o|
52
180
  if !o[:sast] && !o[:sca] && !o[:iac]
53
181
  o[:sast] = true
@@ -57,19 +185,27 @@ module ShieldAst
57
185
  end
58
186
  end
59
187
 
60
- private_class_method def self.display_reports(reports, execution_time)
188
+ def self.display_reports(reports, execution_time)
61
189
  total_issues = 0
62
190
 
63
191
  reports.each do |type, report_data|
64
- results = report_data["results"] || []
192
+ results = report_data[:results] || report_data["results"] || []
65
193
  total_issues += results.length
66
194
 
67
195
  next if results.empty?
68
196
 
69
- puts "\n#{get_scan_icon(type)} #{type.to_s.upcase} (#{results.length} #{results.length == 1 ? "issue" : "issues"})"
197
+ sorted_results = sort_by_severity(results)
198
+ top_results = sorted_results.first(5)
199
+ remaining_count = results.length - top_results.length
200
+
201
+ puts "\n#{get_scan_icon(type)} #{type.to_s.upcase} (#{results.length} #{results.length == 1 ? "issue" : "issues"}#{remaining_count.positive? ? ", showing top 5" : ""})"
70
202
  puts "-" * 60
71
203
 
72
- format_report(results, type)
204
+ format_report(top_results, type)
205
+
206
+ if remaining_count.positive?
207
+ puts " ... and #{remaining_count} more #{remaining_count == 1 ? "issue" : "issues"} (run with --verbose to see all)"
208
+ end
73
209
  end
74
210
 
75
211
  puts "\n✅ Scan finished in: #{format_duration(execution_time)}"
@@ -78,23 +214,35 @@ module ShieldAst
78
214
  puts "✅ No security issues found! Your code looks clean."
79
215
  else
80
216
  severity_summary = calculate_severity_summary(reports)
81
- puts "📊 Total: #{total_issues} findings #{severity_summary}"
217
+ puts "📊 Total: #{total_issues} findings {error_count: #{severity_summary[:error_count]}, warning_count: #{severity_summary[:warning_count]}, info_count: #{severity_summary[:info_count]}}"
218
+ end
219
+ end
220
+
221
+ def self.sort_by_severity(results)
222
+ severity_order = { "ERROR" => 0, "WARNING" => 1, "INFO" => 2 }
223
+
224
+ results.sort_by do |result|
225
+ severity = result[:severity] || result["severity"] || result.dig(:extra,
226
+ :severity) || result.dig("extra",
227
+ "severity") || "INFO"
228
+ severity_order[severity.upcase] || 3
229
+ rescue TypeError
230
+ 3
82
231
  end
83
232
  end
84
233
 
85
- private_class_method def self.format_report(results, scan_type)
234
+ def self.format_report(results, scan_type)
86
235
  results.each_with_index do |result, index|
87
236
  if scan_type == :sca && has_sca_format?(result)
88
237
  format_sca_result(result)
89
238
  else
90
239
  format_default_result(result)
91
240
  end
92
- puts "" if index < results.length - 1 # Add spacing between items, but not after last
241
+ puts "" if index < results.length - 1
93
242
  end
94
243
  end
95
244
 
96
- # Helper methods for better formatting
97
- private_class_method def self.get_severity_icon(severity)
245
+ def self.get_severity_icon(severity)
98
246
  case severity&.upcase
99
247
  when "ERROR" then "🔴"
100
248
  when "WARNING" then "🟡"
@@ -103,7 +251,7 @@ module ShieldAst
103
251
  end
104
252
  end
105
253
 
106
- private_class_method def self.get_scan_icon(scan_type)
254
+ def self.get_scan_icon(scan_type)
107
255
  case scan_type
108
256
  when :sast then "🔍"
109
257
  when :sca then "📦"
@@ -112,23 +260,15 @@ module ShieldAst
112
260
  end
113
261
  end
114
262
 
115
- private_class_method def self.extract_short_description(result)
116
- description = result["extra"]["message"].gsub("\n", " ").strip
117
- if description.length > 80
118
- "#{description[0..80]}..."
119
- else
120
- description
121
- end
122
- end
123
-
124
- private_class_method def self.calculate_severity_summary(reports)
263
+ def self.calculate_severity_summary(reports)
125
264
  error_count = 0
126
265
  warning_count = 0
127
266
  info_count = 0
128
267
 
129
268
  reports.each_value do |report_data|
130
- (report_data["results"] || []).each do |result|
131
- severity = result["severity"] || result.dig("extra", "severity")
269
+ (report_data[:results] || report_data["results"] || []).each do |result|
270
+ severity = result[:severity] || result["severity"] || result.dig(:extra,
271
+ :severity) || result.dig("extra", "severity")
132
272
  case severity&.upcase
133
273
  when "ERROR" then error_count += 1
134
274
  when "WARNING" then warning_count += 1
@@ -137,15 +277,10 @@ module ShieldAst
137
277
  end
138
278
  end
139
279
 
140
- parts = []
141
- parts << "#{error_count} 🔴" if error_count.positive?
142
- parts << "#{warning_count} 🟡" if warning_count.positive?
143
- parts << "#{info_count} 🔵" if info_count.positive?
144
-
145
- "(#{parts.join(", ")})"
280
+ { error_count: error_count, warning_count: warning_count, info_count: info_count }
146
281
  end
147
282
 
148
- private_class_method def self.format_duration(seconds)
283
+ def self.format_duration(seconds)
149
284
  if seconds < 1
150
285
  "#{(seconds * 1000).round}ms"
151
286
  elsif seconds < 60
@@ -157,31 +292,33 @@ module ShieldAst
157
292
  end
158
293
  end
159
294
 
160
- private_class_method def self.has_sca_format?(result)
161
- result.key?("title") && result.key?("description") &&
162
- result.key?("vulnerable_version") && result.key?("fixed_version")
295
+ def self.has_sca_format?(result)
296
+ (result.key?(:title) || result.key?("title")) &&
297
+ (result.key?(:description) || result.key?("description")) &&
298
+ (result.key?(:vulnerable_version) || result.key?("vulnerable_version")) &&
299
+ (result.key?(:fixed_version) || result.key?("fixed_version"))
163
300
  end
164
301
 
165
- private_class_method def self.format_sca_result(result)
166
- severity_icon = get_severity_icon(result['severity'])
167
- puts " #{severity_icon} #{result["title"]} (#{result["vulnerable_version"]} → #{result["fixed_version"]})"
168
- puts " 📁 #{result["file"]} | #{result["description"][0..80]}#{result["description"].length > 80 ? "..." : ""}"
302
+ def self.format_sca_result(result)
303
+ severity_icon = get_severity_icon(result[:severity] || result["severity"])
304
+ puts " #{severity_icon} #{result[:title] || result["title"]} (#{result[:vulnerable_version] || result["vulnerable_version"]} → #{result[:fixed_version] || result["fixed_version"]})"
305
+ puts " 📁 #{result[:file] || result["file"]} | #{(result[:description] || result["description"] || "")[0..80]}#{(result[:description] || result["description"] || "").length > 80 ? "..." : ""}"
169
306
  end
170
307
 
171
- private_class_method def self.format_default_result(result)
172
- severity_icon = get_severity_icon(result["extra"]["severity"])
173
- title = result["extra"]["message"].split(".")[0].strip
174
- file_info = "#{File.basename(result["path"])}:#{result["start"]["line"]}"
308
+ def self.format_default_result(result)
309
+ severity_icon = get_severity_icon(result[:severity] || result["severity"] || result[:extra]&.[](:severity) || result["extra"]&.[]("severity"))
310
+ message = result[:extra]&.[](:message) || result["extra"]&.[]("message") || "Unknown issue"
311
+ title = message.split(".")[0].strip
312
+ file_info = "#{File.basename(result[:path] || result["path"] || "N/A")}:#{result[:start]&.[](:line) || result["start"]&.[]("line") || "N/A"}"
175
313
 
176
314
  puts " #{severity_icon} #{title}"
177
- puts " 📁 #{file_info} | #{extract_short_description(result)}"
315
+ puts " 📁 #{file_info} | #{(result[:extra]&.[](:message) || result["extra"]&.[]("message") || "No description available")[0..80]}..."
178
316
  end
179
317
 
180
- # Parses command-line arguments to build an options hash.
181
- private_class_method def self.parse_args(args)
182
- options = { command: nil, path: nil, sast: false, sca: false, iac: false, help: false, version: false }
183
-
184
- args.each do |arg|
318
+ def self.parse_args(args)
319
+ options = { command: nil, path: nil, sast: false, sca: false, iac: false, help: false, version: false,
320
+ output: nil }
321
+ args.each_with_index do |arg, index|
185
322
  case arg
186
323
  when "scan" then options[:command] = "scan"
187
324
  when "report" then options[:command] = "report"
@@ -190,43 +327,34 @@ module ShieldAst
190
327
  when "-i", "--iac" then options[:iac] = true
191
328
  when "-h", "--help" then options[:help] = true
192
329
  when "--version" then options[:version] = true
330
+ when "-o", "--output"
331
+ options[:output] = args[index + 1] if index + 1 < args.length
193
332
  when /^[^-]/ then options[:path] = arg if options[:command] == "scan" && options[:path].nil?
194
333
  end
195
334
  end
196
335
  options
197
336
  end
198
337
 
199
- # Displays the help message for the CLI tool.
200
- private_class_method def self.show_help
338
+ def self.show_help
201
339
  puts <<~HELP
202
340
  ast - A powerful command-line tool for Application Security Testing
203
-
204
341
  Usage:
205
342
  ast [command] [options]
206
-
207
343
  Commands:
208
344
  scan [path] Scans a directory for vulnerabilities. Defaults to the current directory.
209
- report Generates a detailed report from the last scan.
345
+ report Generates a report from the last scan in JSON or PDF format.
210
346
  help Shows this help message.
211
-
212
347
  Options:
213
348
  -s, --sast Run Static Application Security Testing (SAST) with Semgrep.
214
349
  -c, --sca Run Software Composition Analysis (SCA) with OSV Scanner.
215
350
  -i, --iac Run Infrastructure as Code (IaC) analysis with Semgrep.
216
- -o, --output Specify the output format (e.g., json, sarif, console).
351
+ -o, --output Specify the output format for report (json or pdf, default: json).
217
352
  -h, --help Show this help message.
218
353
  --version Show the ast version.
219
-
220
354
  Examples:
221
- # Scan the current directory for all types of vulnerabilities
222
355
  ast scan
223
-
224
- # Run only SAST and SCA on a specific project folder
225
356
  ast scan /path/to/project --sast --sca
226
-
227
- # Generate a report in SARIF format
228
- ast report --output sarif
229
-
357
+ ast report --output pdf
230
358
  Description:
231
359
  ast is an all-in-one command-line tool that automates security testing by
232
360
  integrating popular open-source scanners for SAST, SCA, and IaC, helping you
metadata CHANGED
@@ -1,14 +1,84 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shield_ast
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jose Augusto
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: erb
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '4.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '4.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: fileutils
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.7'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.7'
40
+ - !ruby/object:Gem::Dependency
41
+ name: json
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.6'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.6'
54
+ - !ruby/object:Gem::Dependency
55
+ name: prawn
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.4'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.4'
68
+ - !ruby/object:Gem::Dependency
69
+ name: prawn-table
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.2'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.2'
12
82
  description: |-
13
83
  Shield AST is an all-in-one command-line tool that automates security testing by integrating
14
84
  popular open-source scanners for SAST, SCA, and IaC, helping you find and fix vulnerabilities
@@ -20,18 +90,13 @@ executables:
20
90
  extensions: []
21
91
  extra_rdoc_files: []
22
92
  files:
23
- - ".idea/.gitignore"
24
- - ".idea/dictionaries/project.xml"
25
- - ".idea/misc.xml"
26
- - ".idea/modules.xml"
27
- - ".idea/shield_ast.iml"
28
- - ".idea/vcs.xml"
29
93
  - CHANGELOG.md
30
94
  - CODE_OF_CONDUCT.md
31
95
  - LICENSE.txt
32
96
  - README.md
33
97
  - Rakefile
34
98
  - exe/ast
99
+ - lib/reports/templates/pdf_report_template.rb
35
100
  - lib/shield_ast.rb
36
101
  - lib/shield_ast/iac.rb
37
102
  - lib/shield_ast/runner.rb
data/.idea/.gitignore DELETED
@@ -1,8 +0,0 @@
1
- # Default ignored files
2
- /shelf/
3
- /workspace.xml
4
- # Editor-based HTTP Client requests
5
- /httpRequests/
6
- # Datasource local storage ignored files
7
- /dataSources/
8
- /dataSources.local.xml
@@ -1,7 +0,0 @@
1
- <component name="ProjectDictionaryState">
2
- <dictionary name="project">
3
- <words>
4
- <w>sast</w>
5
- </words>
6
- </dictionary>
7
- </component>
data/.idea/misc.xml DELETED
@@ -1,4 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <project version="4">
3
- <component name="ProjectRootManager" version="2" project-jdk-name="rbenv: 3.4.5" project-jdk-type="RUBY_SDK" />
4
- </project>
data/.idea/modules.xml DELETED
@@ -1,8 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <project version="4">
3
- <component name="ProjectModuleManager">
4
- <modules>
5
- <module fileurl="file://$PROJECT_DIR$/.idea/shield_ast.iml" filepath="$PROJECT_DIR$/.idea/shield_ast.iml" />
6
- </modules>
7
- </component>
8
- </project>
data/.idea/shield_ast.iml DELETED
@@ -1,48 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <module type="RUBY_MODULE" version="4">
3
- <component name="ModuleRunConfigurationManager">
4
- <shared />
5
- </component>
6
- <component name="NewModuleRootManager">
7
- <content url="file://$MODULE_DIR$">
8
- <sourceFolder url="file://$MODULE_DIR$/features" isTestSource="true" />
9
- <sourceFolder url="file://$MODULE_DIR$/spec" isTestSource="true" />
10
- <sourceFolder url="file://$MODULE_DIR$/test" isTestSource="true" />
11
- </content>
12
- <orderEntry type="inheritedJdk" />
13
- <orderEntry type="sourceFolder" forTests="false" />
14
- <orderEntry type="library" scope="PROVIDED" name="ast (v2.4.3, rbenv: 3.4.5) [gem]" level="application" />
15
- <orderEntry type="library" scope="PROVIDED" name="bundler (v2.7.1, rbenv: 3.4.5) [gem]" level="application" />
16
- <orderEntry type="library" scope="PROVIDED" name="date (v3.4.1, rbenv: 3.4.5) [gem]" level="application" />
17
- <orderEntry type="library" scope="PROVIDED" name="diff-lcs (v1.6.2, rbenv: 3.4.5) [gem]" level="application" />
18
- <orderEntry type="library" scope="PROVIDED" name="erb (v5.0.2, rbenv: 3.4.5) [gem]" level="application" />
19
- <orderEntry type="library" scope="PROVIDED" name="io-console (v0.8.1, rbenv: 3.4.5) [gem]" level="application" />
20
- <orderEntry type="library" scope="PROVIDED" name="irb (v1.15.2, rbenv: 3.4.5) [gem]" level="application" />
21
- <orderEntry type="library" scope="PROVIDED" name="json (v2.13.2, rbenv: 3.4.5) [gem]" level="application" />
22
- <orderEntry type="library" scope="PROVIDED" name="language_server-protocol (v3.17.0.5, rbenv: 3.4.5) [gem]" level="application" />
23
- <orderEntry type="library" scope="PROVIDED" name="lint_roller (v1.1.0, rbenv: 3.4.5) [gem]" level="application" />
24
- <orderEntry type="library" scope="PROVIDED" name="parallel (v1.27.0, rbenv: 3.4.5) [gem]" level="application" />
25
- <orderEntry type="library" scope="PROVIDED" name="parser (v3.3.9.0, rbenv: 3.4.5) [gem]" level="application" />
26
- <orderEntry type="library" scope="PROVIDED" name="pp (v0.6.2, rbenv: 3.4.5) [gem]" level="application" />
27
- <orderEntry type="library" scope="PROVIDED" name="prettyprint (v0.2.0, rbenv: 3.4.5) [gem]" level="application" />
28
- <orderEntry type="library" scope="PROVIDED" name="prism (v1.4.0, rbenv: 3.4.5) [gem]" level="application" />
29
- <orderEntry type="library" scope="PROVIDED" name="psych (v5.2.6, rbenv: 3.4.5) [gem]" level="application" />
30
- <orderEntry type="library" scope="PROVIDED" name="racc (v1.8.1, rbenv: 3.4.5) [gem]" level="application" />
31
- <orderEntry type="library" scope="PROVIDED" name="rainbow (v3.1.1, rbenv: 3.4.5) [gem]" level="application" />
32
- <orderEntry type="library" scope="PROVIDED" name="rake (v13.3.0, rbenv: 3.4.5) [gem]" level="application" />
33
- <orderEntry type="library" scope="PROVIDED" name="rdoc (v6.14.2, rbenv: 3.4.5) [gem]" level="application" />
34
- <orderEntry type="library" scope="PROVIDED" name="regexp_parser (v2.11.0, rbenv: 3.4.5) [gem]" level="application" />
35
- <orderEntry type="library" scope="PROVIDED" name="reline (v0.6.2, rbenv: 3.4.5) [gem]" level="application" />
36
- <orderEntry type="library" scope="PROVIDED" name="rspec (v3.13.1, rbenv: 3.4.5) [gem]" level="application" />
37
- <orderEntry type="library" scope="PROVIDED" name="rspec-core (v3.13.5, rbenv: 3.4.5) [gem]" level="application" />
38
- <orderEntry type="library" scope="PROVIDED" name="rspec-expectations (v3.13.5, rbenv: 3.4.5) [gem]" level="application" />
39
- <orderEntry type="library" scope="PROVIDED" name="rspec-mocks (v3.13.5, rbenv: 3.4.5) [gem]" level="application" />
40
- <orderEntry type="library" scope="PROVIDED" name="rspec-support (v3.13.4, rbenv: 3.4.5) [gem]" level="application" />
41
- <orderEntry type="library" scope="PROVIDED" name="rubocop (v1.79.1, rbenv: 3.4.5) [gem]" level="application" />
42
- <orderEntry type="library" scope="PROVIDED" name="rubocop-ast (v1.46.0, rbenv: 3.4.5) [gem]" level="application" />
43
- <orderEntry type="library" scope="PROVIDED" name="ruby-progressbar (v1.13.0, rbenv: 3.4.5) [gem]" level="application" />
44
- <orderEntry type="library" scope="PROVIDED" name="stringio (v3.1.7, rbenv: 3.4.5) [gem]" level="application" />
45
- <orderEntry type="library" scope="PROVIDED" name="unicode-display_width (v3.1.4, rbenv: 3.4.5) [gem]" level="application" />
46
- <orderEntry type="library" scope="PROVIDED" name="unicode-emoji (v4.0.4, rbenv: 3.4.5) [gem]" level="application" />
47
- </component>
48
- </module>
data/.idea/vcs.xml DELETED
@@ -1,6 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <project version="4">
3
- <component name="VcsDirectoryMappings">
4
- <mapping directory="" vcs="Git" />
5
- </component>
6
- </project>