Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add markdown format for easy pasting into maintenance logs. #53

Merged
merged 14 commits into from
Feb 2, 2024
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,16 +141,22 @@ gem install package-audit
package-audit --group staging --group production [DIR]
```

* To show how risk is calculated for the above report run:
* To produce the same report in a CSV format run:

```bash
package-audit risk
package-audit --format csv
```

* To produce the same report in a CSV format run:
* To produce the same report in a Markdown format run:

```bash
package-audit --format md
```

* To show how risk is calculated for the above report run:

```bash
package-audit --csv
package-audit risk
```

#### For a list of all commands and their options run:
Expand Down
9 changes: 5 additions & 4 deletions lib/package/audit/cli.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
require_relative 'const/file'
require_relative 'const/time'
require_relative 'enum/format'
require_relative 'enum/option'
require_relative 'services/command_parser'
require_relative 'util//risk_legend'
Expand All @@ -25,12 +26,12 @@ class CLI < Thor
class_option Enum::Option::INCLUDE_IGNORED,
type: :boolean, default: false,
desc: 'Include packages ignored by a configuration file'
class_option Enum::Option::CSV,
type: :boolean, default: false,
desc: 'Output reports using comma separated values (CSV)'
class_option Enum::Option::FORMAT,
aliases: '-f', banner: Enum::Format.all.join('|'), type: :string,
desc: 'Output reports using a different format (e.g. CSV or Markdown)'
class_option Enum::Option::CSV_EXCLUDE_HEADERS,
type: :boolean, default: false,
desc: "Hide headers when using the --#{Enum::Option::CSV} option"
desc: "Hide headers when using the #{Enum::Format::CSV} format"

map '-v' => :version
map '--version' => :version
Expand Down
14 changes: 14 additions & 0 deletions lib/package/audit/enum/format.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module Package
module Audit
module Enum
module Format
CSV = 'csv'
MARKDOWN = 'md'

def self.all
constants.map { |key| const_get(key) }.sort
end
end
end
end
end
2 changes: 1 addition & 1 deletion lib/package/audit/enum/option.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module Audit
module Enum
module Option
CONFIG = 'config'
CSV = 'csv'
FORMAT = 'format'
CSV_EXCLUDE_HEADERS = 'exclude-headers'
GROUP = 'group'
INCLUDE_IGNORED = 'include-ignored'
Expand Down
2 changes: 1 addition & 1 deletion lib/package/audit/enum/technology.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module Technology
RUBY = 'ruby'

def self.all
constants.map { |key| Enum::Technology.const_get(key) }.sort
constants.map { |key| const_get(key) }.sort
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/package/audit/models/package.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def risk?
end

def group_list
@groups.join('|')
@groups.join(' ')
end

def vulnerabilities_grouped
Expand Down
21 changes: 14 additions & 7 deletions lib/package/audit/services/command_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ def initialize(dir, options, report)
@dir = dir
@options = options
@report = report
@config = parse_config_file
@config = parse_config_file!
@groups = @options[Enum::Option::GROUP]
@technologies = parse_technologies
@technologies = parse_technologies!
validate_format!
@spinner = Util::Spinner.new('Evaluating packages and their dependencies...')
end

Expand Down Expand Up @@ -71,13 +72,13 @@ def process_technologies # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticCo

def print_results(technology, pkgs, ignored_pkgs)
PackagePrinter.new(@options, pkgs).print(Const::Fields::DEFAULT)
print_summary(technology, pkgs, ignored_pkgs) unless @options[Enum::Option::CSV]
print_disclaimer(technology) unless @options[Enum::Option::CSV] || pkgs.empty?
print_summary(technology, pkgs, ignored_pkgs) unless @options[Enum::Option::FORMAT] == Enum::Format::CSV
print_disclaimer(technology) unless @options[Enum::Option::FORMAT] == Enum::Format::CSV || pkgs.empty?
end

def print_summary(technology, pkgs, ignored_pkgs)
if @report == Enum::Report::ALL
Util::SummaryPrinter.statistics(technology, @report, pkgs, ignored_pkgs)
Util::SummaryPrinter.statistics(@options[Enum::Option::FORMAT], technology, @report, pkgs, ignored_pkgs)
else
Util::SummaryPrinter.total(technology, @report, pkgs, ignored_pkgs)
end
Expand All @@ -103,7 +104,7 @@ def learn_more_command(technology)
end
end

def parse_config_file
def parse_config_file!
if @options[Enum::Option::CONFIG].nil?
YAML.load_file("#{@dir}/#{Const::File::CONFIG}") if File.exist? "#{@dir}/#{Const::File::CONFIG}"
elsif File.exist? @options[Enum::Option::CONFIG]
Expand All @@ -113,7 +114,13 @@ def parse_config_file
end
end

def parse_technologies
def validate_format!
format = @options[Enum::Option::FORMAT]
raise ArgumentError, "Invalid format: #{format}, should be one of [#{Enum::Format.all.join('|')}]" unless
@options[Enum::Option::FORMAT].nil? || Enum::Format.all.include?(format)
end

def parse_technologies!
technology_validator = Technology::Validator.new(@dir)
@options[Enum::Option::TECHNOLOGY]&.each { |technology| technology_validator.validate! technology }
@options[Enum::Option::TECHNOLOGY] || Technology::Detector.new(@dir).detect
Expand Down
121 changes: 65 additions & 56 deletions lib/package/audit/services/package_printer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ def print(fields)
check_fields(fields)
return if @pkgs.empty?

if @options[Enum::Option::CSV]
case @options[Enum::Option::FORMAT]
when Enum::Format::CSV
csv(fields, exclude_headers: @options[Enum::Option::CSV_EXCLUDE_HEADERS])
when Enum::Format::MARKDOWN
markdown(fields)
else
pretty(fields)
end
Expand All @@ -39,72 +42,78 @@ def check_fields(fields)
"Available fields names are: #{Const::Fields::DEFAULT}."
end

def pretty(fields = Const::Fields::DEFAULT) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
# find the maximum length of each field across all the packages so we know how many
# characters of horizontal space to allocate for each field when printing
fields.each do |key|
instance_variable_set :"@max_#{key}", Const::Fields::HEADERS[key].length
@pkgs.each do |gem|
curr_field_length = case key
when :vulnerabilities
gem.vulnerabilities_grouped.length
when :groups
gem.group_list.length
else
gem.send(key)&.gsub(BASH_FORMATTING_REGEX, '')&.length || 0
end
max_field_length = instance_variable_get :"@max_#{key}"
instance_variable_set :"@max_#{key}", [curr_field_length, max_field_length].max
end
end

line_length = fields.sum { |key| instance_variable_get :"@max_#{key}" } +
(COLUMN_GAP * (fields.length - 1))
def pretty(fields = Const::Fields::DEFAULT) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
max_widths = get_field_max_widths(fields)
header = fields.map.with_index do |field, index|
Const::Fields::HEADERS[field].gsub(BASH_FORMATTING_REGEX, '').ljust(max_widths[index])
end.join(' ' * COLUMN_GAP)
separator = max_widths.map { |width| '=' * width }.join('=' * COLUMN_GAP)

puts '=' * line_length
puts fields.map { |key|
Const::Fields::HEADERS[key].gsub(BASH_FORMATTING_REGEX, '').ljust(instance_variable_get(:"@max_#{key}"))
}.join(' ' * COLUMN_GAP)
puts '=' * line_length
puts separator
puts header
puts separator

@pkgs.each do |pkg|
puts fields.map { |key|
val = pkg.send(key) || ''
val = case key
when :groups
pkg.group_list
when :risk_type
Formatter::Risk.new(pkg.risk_type).format
when :version
Formatter::Version.new(pkg.version, pkg.latest_version).format
when :vulnerabilities
Formatter::Vulnerability.new(pkg.vulnerabilities).format
when :latest_version_date
Formatter::VersionDate.new(pkg.latest_version_date).format
else
val
end

puts fields.map.with_index { |key, index|
val = get_field_value(pkg, key)
formatting_length = val.length - val.gsub(BASH_FORMATTING_REGEX, '').length
val.ljust(instance_variable_get(:"@max_#{key}") + formatting_length)
val.ljust(max_widths[index] + formatting_length)
}.join(' ' * COLUMN_GAP)
end
end

def csv(fields, exclude_headers: false)
value_fields = fields.map do |field|
case field
when :groups
:group_list
when :vulnerabilities
:vulnerabilities_grouped
else
field
def csv(fields = Const::Fields::DEFAULT, exclude_headers: false)
puts fields.join(',') unless exclude_headers
@pkgs.map do |pkg|
puts fields.map { |field| get_field_value(pkg, field) }.join(',').gsub(BASH_FORMATTING_REGEX, '')
end
end

def markdown(fields = Const::Fields::DEFAULT) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
max_widths = get_field_max_widths(fields)
header = fields.map.with_index do |field, index|
Const::Fields::HEADERS[field].gsub(BASH_FORMATTING_REGEX, '').ljust(max_widths[index])
end.join(' | ')
separator = max_widths.map { |width| ":#{'-' * width}" }.join('-|')

puts "| #{header} |"
puts "|#{separator}-|"

@pkgs.each do |pkg|
row = fields.map.with_index do |key, index|
val = get_field_value(pkg, key)
formatting_length = val.length - val.gsub(BASH_FORMATTING_REGEX, '').length
val.ljust(max_widths[index] + formatting_length)
end
puts "| #{row.join(' | ')} |"
end
end

puts fields.join(',') unless exclude_headers
@pkgs.map { |gem| puts gem.to_csv(value_fields) }
def get_field_max_widths(fields)
# Calculate the maximum width for each column, including header titles and content
fields.map do |field|
[@pkgs.map do |pkg|
value = get_field_value(pkg, field).to_s.gsub(BASH_FORMATTING_REGEX, '').length
value
end.max, Const::Fields::HEADERS[field].gsub(BASH_FORMATTING_REGEX, '').length].max
end
end

def get_field_value(pkg, field) # rubocop:disable Metrics/MethodLength
case field
when :groups
pkg.group_list
when :risk_type
Formatter::Risk.new(pkg.risk_type).format
when :version
Formatter::Version.new(pkg.version, pkg.latest_version).format
when :vulnerabilities
Formatter::Vulnerability.new(pkg.vulnerabilities).format
when :latest_version_date
Formatter::VersionDate.new(pkg.latest_version_date).format
else
pkg.send(field) || ''
end
end
end
end
Expand Down
14 changes: 9 additions & 5 deletions lib/package/audit/util/summary_printer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ def self.total(technology, report, pkgs, ignored_pkgs)
end
end

def self.statistics(technology, report, pkgs, ignored_pkgs)
def self.statistics(format, technology, report, pkgs, ignored_pkgs)
stats = calculate_statistics(pkgs, ignored_pkgs)
display_results(technology, report, pkgs, ignored_pkgs, stats)
display_results(format, technology, report, pkgs, ignored_pkgs, stats)
end

private_class_method def self.calculate_statistics(pkgs, ignored_pkgs)
Expand All @@ -56,12 +56,16 @@ def self.statistics(technology, report, pkgs, ignored_pkgs)
pkgs.count(&status)
end

private_class_method def self.display_results(technology, report, pkgs, ignored_pkgs, stats)
private_class_method def self.display_results(format, technology, report, pkgs, ignored_pkgs, stats) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists
if pkgs.any?
puts status_message(stats)
print status_message(stats)
print Util::BashColor.cyan(' \\') if format == Enum::Format::MARKDOWN
puts
total(technology, report, pkgs, ignored_pkgs)
elsif ignored_pkgs.any?
puts status_message(stats)
print status_message(stats)
print Util::BashColor.cyan(' \\') if format == Enum::Format::MARKDOWN
puts
puts Util::BashColor.green("There are no deprecated, outdated or vulnerable #{technology} " \
"packages (#{ignored_pkgs.length} ignored)!\n")
else
Expand Down
12 changes: 12 additions & 0 deletions sig/package/audit/enum/format.rbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
module Package
module Audit
module Enum
module Format
CSV: String
MARKDOWN: String

def self.all: -> Array[String]
end
end
end
end
2 changes: 1 addition & 1 deletion sig/package/audit/enum/option.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module Package
module Enum
module Option
CONFIG: String
CSV: String
FORMAT: String
CSV_EXCLUDE_HEADERS: String
GROUP: String
INCLUDE_IGNORED: String
Expand Down
6 changes: 5 additions & 1 deletion sig/package/audit/services/command_parser.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,21 @@ module Package

def learn_more_command: (String) -> String?

def parse_config_file: -> Hash[String, untyped]?
def parse_config_file!: -> Hash[String, untyped]?

def parse_technologies: -> Array[String]

def parse_technologies!: -> Array[String]

def print_disclaimer: (String) -> void

def print_results: (String, Array[Package], Array[Package]) -> void

def print_summary: (String, Array[Package], Array[Package]) -> void

def process_technologies: -> int

def validate_format!: -> void
end
end
end
8 changes: 7 additions & 1 deletion sig/package/audit/services/package_printer.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ module Package

def csv: (Array[Symbol], ?exclude_headers: bool) -> void

def pretty: (?Array[Symbol]) -> void
def get_field_max_widths: (Array[Symbol]) -> Array[Integer]

def get_field_value: (Package, Symbol) -> String

def markdown: (Array[Symbol]) -> void

def pretty: (Array[Symbol]) -> void
end
end
end
Loading
Loading