diff --git a/lib/parsers/audit_parser.rb b/lib/parsers/audit_parser.rb index ad076e9..b73d536 100644 --- a/lib/parsers/audit_parser.rb +++ b/lib/parsers/audit_parser.rb @@ -1,30 +1,26 @@ # frozen_string_literal: true +require_relative "markdown_table_parser" + module ImportmapUpdate module Parsers - # Parses the text output of `bin/importmap audit`. - # - # Expected format (see importmap-rails lib/importmap/commands.rb#audit): - # - # | Package | Severity | Vulnerable versions | Vulnerability | - # |---------|----------|---------------------|----------------------| - # | lodash | high | <4.17.21 | Prototype Pollution | - # 2 vulnerabilities found: 1 high, 1 moderate - # - # When no vulnerabilities exist: - # - # No vulnerable packages found - # - # The Vulnerability column comes from the npm advisory database and is - # free-form text. If it ever contains a literal `|`, we rejoin the - # overflow cells so the description survives intact. class AuditParser - Vulnerability = Data.define(:name, :severity, :vulnerable_versions, :advisory) - SEVERITIES = %w[low moderate high critical].freeze + DEFAULT_SEVERITY_LEVEL = 0 - EMPTY_MESSAGE = "No vulnerable packages found" - DIVIDER_RE = /\A\|[-|]+\|\z/ + SeverityLevel = Data.define(:level) do + def self.from_name(name) + level = SEVERITIES.index(name) || DEFAULT_SEVERITY_LEVEL + + new(level) + end + + def to_s = SEVERITIES[level] + + def inspect = "SeverityLevel(#{self})" + end + + Vulnerability = Data.define(:name, :severity, :vulnerable_versions, :advisory) def self.parse(output) new(output).parse @@ -35,39 +31,17 @@ def initialize(output) end def parse - lines = @output.each_line.map(&:chomp) - return [] if lines.any? { |l| l.strip == EMPTY_MESSAGE } - - header_idx = lines.index { |l| l.start_with?("|") && l.include?("Severity") } - return [] unless header_idx - - rows = [] - lines[(header_idx + 1)..].each do |line| - break unless line.start_with?("|") - next if DIVIDER_RE.match?(line) - cells = split_row(line) - next unless cells.size >= 4 - rows << build_row(cells) + table = MarkdownTableParser.parse(@output) + return [] if table.empty? + + table.map do |row| + Vulnerability.new( + name: row[:package], + severity: SeverityLevel.from_name(row[:severity]), + vulnerable_versions: row[:vulnerable_versions], + advisory: row[:vulnerability] + ) end - rows - end - - private - - def split_row(line) - line.split("|").map(&:strip).reject(&:empty?) - end - - # If a description contained a `|`, cells.size will be >4. Rejoin the - # tail into the advisory column so we don't lose information. - def build_row(cells) - name, severity, vulnerable_versions, *advisory_parts = cells - Vulnerability.new( - name:, - severity:, - vulnerable_versions:, - advisory: advisory_parts.join(" | ") - ) end end end diff --git a/lib/parsers/markdown_table_parser.rb b/lib/parsers/markdown_table_parser.rb new file mode 100644 index 0000000..b04d9bb --- /dev/null +++ b/lib/parsers/markdown_table_parser.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module ImportmapUpdate + module Parsers + class MarkdownTableParser + def self.parse(output) + new(output).parse + end + + def initialize(output) + @output = output.to_s + end + + def parse + lines = @output.each_line.map(&:strip).select { _1.start_with?("|") }.reject { _1.start_with?("|-") } + + return [] if lines.empty? + + header = lines.shift.split("|")[1..].map { symbolize(_1) } + body = lines.map { |l| l.split("|")[1..].map(&:strip) } + + body.map { |row| header.zip(row).to_h } + end + + private + + def symbolize(string) + string.strip.downcase.gsub(/\s+/, "_").to_sym + end + end + end +end diff --git a/lib/parsers/outdated_parser.rb b/lib/parsers/outdated_parser.rb index b7c4394..c8afe73 100644 --- a/lib/parsers/outdated_parser.rb +++ b/lib/parsers/outdated_parser.rb @@ -1,38 +1,30 @@ # frozen_string_literal: true +require_relative "markdown_table_parser" + module ImportmapUpdate module Parsers - # Parses the text output of `bin/importmap outdated`. - # - # Expected format (see importmap-rails lib/importmap/commands.rb#outdated): - # - # | Package | Current | Latest | - # |---------|---------|--------| - # | lodash | 4.17.20 | 4.17.21 | - # 1 outdated package found - # - # When no outdated packages exist, the command prints only: - # - # No outdated packages found - # - # The "Latest" column can also contain an error string (e.g. an HTTP - # status from a failed lookup) when latest_version is nil on the - # underlying OutdatedPackage. Those rows are returned with `error: ...` - # set and `latest: nil`, so callers can decide whether to skip them. class OutdatedParser - OutdatedPackage = Data.define(:name, :current, :latest, :error) do + VERSION_SHAPE_RE = /\Av?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\z/ + + OutdatedPackage = Data.define(:name, :current, :latest_or_error) do + def latest + return nil unless VERSION_SHAPE_RE.match?(latest_or_error) + + latest_or_error + end + + def error + return nil if VERSION_SHAPE_RE.match?(latest_or_error) + + latest_or_error + end + def parseable? !latest.nil? end end - EMPTY_MESSAGE = "No outdated packages found" - DIVIDER_RE = /\A\|[-|]+\|\z/ - # Cheap shape check for "looks like a version" — we don't need full - # SemVer parsing here, just enough to decide "is this a version or - # an error message?". Pre-release tags and `v` prefixes are allowed. - VERSION_SHAPE_RE = /\Av?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\z/ - def self.parse(output) new(output).parse end @@ -42,37 +34,16 @@ def initialize(output) end def parse - lines = @output.each_line.map(&:chomp) - return [] if lines.any? { |l| l.strip == EMPTY_MESSAGE } - - header_idx = lines.index { |l| l.start_with?("|") && l.include?("Package") } - return [] unless header_idx - - rows = [] - lines[(header_idx + 1)..].each do |line| - break unless line.start_with?("|") - next if DIVIDER_RE.match?(line) - cells = split_row(line) - next unless cells.size >= 3 - rows << build_row(cells) + table = MarkdownTableParser.parse(@output) + return [] if table.empty? + + table.map do |row| + OutdatedPackage.new( + name: row[:package], + current: row[:current], + latest_or_error: row[:latest] + ) end - rows - end - - private - - # `| a | b | c |` → ["a", "b", "c"] - # We drop the empty strings produced by the leading and trailing pipes. - def split_row(line) - line.split("|").map(&:strip).reject(&:empty?) - end - - def build_row(cells) - name, current, latest_or_error = cells - latest = latest_or_error if VERSION_SHAPE_RE.match?(latest_or_error) - error = latest_or_error unless VERSION_SHAPE_RE.match?(latest_or_error) - - OutdatedPackage.new(name:, current:, latest:, error:) end end end diff --git a/test/parsers/audit_parser_test.rb b/test/parsers/audit_parser_test.rb new file mode 100644 index 0000000..8be3dcc --- /dev/null +++ b/test/parsers/audit_parser_test.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require_relative "../test_helper" +require "parsers/audit_parser" + +class AuditParserTest < Minitest::Test + Parser = ImportmapUpdate::Parsers::AuditParser + + def test_parses_basic_audit_output + result = Parser.parse(fixture("audit_basic.txt")) + + assert_equal 2, result.size + + lodash = result[0] + assert_equal "lodash", lodash.name + assert_equal "high", lodash.severity.to_s + assert_equal "<4.17.21", lodash.vulnerable_versions + assert_equal "Prototype Pollution in lodash", lodash.advisory + + stimulus = result[1] + assert_equal "@hotwired/stimulus", stimulus.name + assert_equal "moderate", stimulus.severity.to_s + end + + def test_parses_single_critical + result = Parser.parse(fixture("audit_critical.txt")) + assert_equal 1, result.size + assert_equal "critical", result[0].severity.to_s + assert_equal "evil-pkg", result[0].name + end + + def test_empty_output_returns_empty_array + assert_empty Parser.parse(fixture("audit_empty.txt")) + end + + def test_blank_input_returns_empty_array + assert_empty Parser.parse("") + assert_empty Parser.parse(nil) + end +end diff --git a/test/parsers/markdown_table_parser_test.rb b/test/parsers/markdown_table_parser_test.rb new file mode 100644 index 0000000..805d37f --- /dev/null +++ b/test/parsers/markdown_table_parser_test.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require_relative "../test_helper" +require "parsers/markdown_table_parser" + +class MarkdownTableParserTest < Minitest::Test + Parser = ImportmapUpdate::Parsers::MarkdownTableParser + + def test_parses_audit_markdown_table + table = <<~MARKDOWN + | Package | Severity | Vulnerable versions | Vulnerability | + |--------------------|----------|---------------------|-------------------------------| + | lodash | high | <4.17.21 | Prototype Pollution in lodash | + | @hotwired/stimulus | moderate | <3.2.2 | ReDoS in stimulus router | + 2 vulnerabilities found: 1 high, 1 moderate + MARKDOWN + + result = Parser.parse(table) + + expected = [ + {package: "lodash", severity: "high", vulnerable_versions: "<4.17.21", vulnerability: "Prototype Pollution in lodash"}, + {package: "@hotwired/stimulus", severity: "moderate", vulnerable_versions: "<3.2.2", vulnerability: "ReDoS in stimulus router"} + ] + + assert_equal expected, result + end + + def test_parses_outdated_markdown_table + table = <<~MARKDOWN + | Package | Current | Latest | + |--------------------|---------|---------| + | @hotwired/stimulus | 3.2.1 | 3.2.2 | + | lodash | 4.17.20 | 4.17.21 | + | react | 18.2.0 | 19.0.0 | + 3 outdated packages found + MARKDOWN + + result = Parser.parse(table) + + expected = [ + {package: "@hotwired/stimulus", current: "3.2.1", latest: "3.2.2"}, + {package: "lodash", current: "4.17.20", latest: "4.17.21"}, + {package: "react", current: "18.2.0", latest: "19.0.0"} + ] + + assert_equal expected, result + end +end diff --git a/test/parsers/test_outdated_parser.rb b/test/parsers/outdated_parser_test.rb similarity index 100% rename from test/parsers/test_outdated_parser.rb rename to test/parsers/outdated_parser_test.rb diff --git a/test/parsers/test_audit_parser.rb b/test/parsers/test_audit_parser.rb deleted file mode 100644 index d5b9545..0000000 --- a/test/parsers/test_audit_parser.rb +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -require_relative "../test_helper" -require "parsers/audit_parser" - -class AuditParserTest < Minitest::Test - Parser = ImportmapUpdate::Parsers::AuditParser - - def test_parses_basic_audit_output - result = Parser.parse(fixture("audit_basic.txt")) - - assert_equal 2, result.size - - lodash = result[0] - assert_equal "lodash", lodash.name - assert_equal "high", lodash.severity - assert_equal "<4.17.21", lodash.vulnerable_versions - assert_equal "Prototype Pollution in lodash", lodash.advisory - - stimulus = result[1] - assert_equal "@hotwired/stimulus", stimulus.name - assert_equal "moderate", stimulus.severity - end - - def test_parses_single_critical - result = Parser.parse(fixture("audit_critical.txt")) - assert_equal 1, result.size - assert_equal "critical", result[0].severity - assert_equal "evil-pkg", result[0].name - end - - def test_empty_output_returns_empty_array - assert_empty Parser.parse(fixture("audit_empty.txt")) - end - - def test_blank_input_returns_empty_array - assert_empty Parser.parse("") - assert_empty Parser.parse(nil) - end - - def test_advisory_with_embedded_pipe_is_preserved - # Synthesised: if npm ever returns a description with a literal `|`, - # we want to keep the description intact rather than truncating it - # or treating it as a parse error. - output = <<~OUT - | Package | Severity | Vulnerable versions | Vulnerability | - |---------|----------|---------------------|-----------------------------------------| - | x | high | <1.0.0 | CVE-2024-1234 | command injection in x | - 1 vulnerability found: 1 high - OUT - result = Parser.parse(output) - assert_equal 1, result.size - assert_includes result[0].advisory, "CVE-2024-1234" - assert_includes result[0].advisory, "command injection in x" - end - - def test_known_severities_constant_is_ordered_low_to_critical - # The planner will sort vulnerabilities by severity; lock this order in - # here so a change to it is a deliberate, test-flagged change. - assert_equal %w[low moderate high critical], Parser::SEVERITIES - end -end diff --git a/test/planner_test.rb b/test/planner_test.rb index a9b0869..2a0dd07 100644 --- a/test/planner_test.rb +++ b/test/planner_test.rb @@ -15,7 +15,7 @@ class PlannerTest < Minitest::Test # ---- helpers ---- def outdated(name, from, to, error: nil) - Outdated.new(name:, current: from, latest: to, error:) + Outdated.new(name:, current: from, latest_or_error: error || to) end def vuln(name, severity, vulnerable: "<#{name}", advisory: "Vulnerability in #{name}")