Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 26 additions & 52 deletions lib/parsers/audit_parser.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
32 changes: 32 additions & 0 deletions lib/parsers/markdown_table_parser.rb
Original file line number Diff line number Diff line change
@@ -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
81 changes: 26 additions & 55 deletions lib/parsers/outdated_parser.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
40 changes: 40 additions & 0 deletions test/parsers/audit_parser_test.rb
Original file line number Diff line number Diff line change
@@ -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
48 changes: 48 additions & 0 deletions test/parsers/markdown_table_parser_test.rb
Original file line number Diff line number Diff line change
@@ -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
62 changes: 0 additions & 62 deletions test/parsers/test_audit_parser.rb

This file was deleted.

2 changes: 1 addition & 1 deletion test/planner_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down