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 outdated and audit commands #109

Merged
merged 1 commit into from
May 22, 2022
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
Add outdated and audit commands
  • Loading branch information
cover committed Mar 7, 2022
commit 23648e8968c329e30871dc5fb5e0b6bac453c6e6
59 changes: 59 additions & 0 deletions lib/importmap/commands.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
require "thor"
require "importmap/packager"
require "importmap/npm"

class Importmap::Commands < Thor
include Thor::Actions
Expand Down Expand Up @@ -63,11 +64,54 @@ def json
puts Rails.application.importmap.to_json(resolver: ActionController::Base.helpers)
end

desc "audit", "Run a security audit"
def audit
vulnerable_packages = npm.vulnerable_packages

if vulnerable_packages.any?
table = [["Package", "Severity", "Vulnerable versions", "Vulnerability"]]
vulnerable_packages.each { |p| table << [p.name, p.severity, p.vulnerable_versions, p.vulnerability] }

puts_table(table)
vulnerabilities = 'vulnerability'.pluralize(vulnerable_packages.size)
severities = vulnerable_packages.map(&:severity).tally.sort_by(&:last).reverse
.map { |severity, count| "#{count} #{severity}" }
.join(", ")
puts " #{vulnerable_packages.size} #{vulnerabilities} found: #{severities}"

exit 1
else
puts "No vulnerable packages found"
end
end

desc "outdated", "Check for outdated packages"
def outdated
outdated_packages = npm.outdated_packages

if outdated_packages.any?
table = [["Package", "Current", "Latest"]]
outdated_packages.each { |p| table << [p.name, p.current_version, p.latest_version || p.error] }

puts_table(table)
packages = 'package'.pluralize(outdated_packages.size)
puts " #{outdated_packages.size} outdated #{packages} found"

exit 1
else
puts "No outdated packages found"
end
end

private
def packager
@packager ||= Importmap::Packager.new
end

def npm
@npm ||= Importmap::Npm.new
end

def remove_line_from_file(path, pattern)
path = File.expand_path(path, destination_root)

Expand All @@ -78,6 +122,21 @@ def remove_line_from_file(path, pattern)
with_lines_removed.each { |line| file.write(line) }
end
end

def puts_table(array)
column_sizes = array.reduce([]) do |lengths, row|
row.each_with_index.map{ |iterand, index| [lengths[index] || 0, iterand.to_s.length].max }
end

puts head = "+" + (column_sizes.map { |s| "-" * (s + 2) }.join('+')) + '+'
array.each_with_index do |row, row_number|
row = row.fill(nil, row.size..(column_sizes.size - 1))
row = row.each_with_index.map { |v, i| v.to_s + " " * (column_sizes[i] - v.to_s.length) }
puts "| " + row.join(" | ") + " |"
puts head if row_number == 0
end
puts head
end
end

Importmap::Commands.start(ARGV)
113 changes: 113 additions & 0 deletions lib/importmap/npm.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
require "net/http"
require "uri"
require "json"

class Importmap::Npm
Error = Class.new(StandardError)
HTTPError = Class.new(Error)

singleton_class.attr_accessor :base_uri
self.base_uri = URI("https://registry.npmjs.org")

def initialize(importmap_path = "config/importmap.rb")
@importmap_path = Pathname.new(importmap_path)
end

def outdated_packages
packages_with_versions.each.with_object([]) do |(package, current_version), outdated_packages|
outdated_package = OutdatedPackage.new(name: package,
current_version: current_version)

if !(response = get_package(package))
outdated_package.error = 'Response error'
elsif (error = response['error'])
outdated_package.error = error
else
latest_version = find_latest_version(response)
next unless outdated?(current_version, latest_version)

outdated_package.latest_version = latest_version
end

outdated_packages << outdated_package
end.sort_by(&:name)
end

def vulnerable_packages
get_audit.flat_map do |package, vulnerabilities|
vulnerabilities.map do |vulnerability|
VulnerablePackage.new(name: package,
severity: vulnerability['severity'],
vulnerable_versions: vulnerability['vulnerable_versions'],
vulnerability: vulnerability['title'])
end
end.sort_by { |p| [p.name, p.severity] }
end

private
OutdatedPackage = Struct.new(:name, :current_version, :latest_version, :error, keyword_init: true)
VulnerablePackage = Struct.new(:name, :severity, :vulnerable_versions, :vulnerability, keyword_init: true)

def packages_with_versions
# We cannot use the name after "pin" because some dependencies are loaded from inside packages
# Eg. pin "buffer", to: "https://ga.jspm.io/npm:@jspm/core@2.0.0-beta.19/nodelibs/browser/buffer.js"

importmap.scan(/^pin .*(?<=npm:|npm\/|skypack\.dev\/|unpkg\.com\/)(.*)(?=@\d+\.\d+\.\d+)@(\d+\.\d+\.\d+(?:[^\/\s"]*)).*$/) |
importmap.scan(/^pin "([^"]*)".* #.*@(\d+\.\d+\.\d+(?:[^\s]*)).*$/)
end

def importmap
@importmap ||= File.read(@importmap_path)
end

def get_package(package)
uri = self.class.base_uri.dup
uri.path = "/" + package
response = get_json(uri)

JSON.parse(response)
rescue JSON::ParserError
nil
end

def get_json(uri)
Net::HTTP.get(uri, "Content-Type" => "application/json")
rescue => error
raise HTTPError, "Unexpected transport error (#{error.class}: #{error.message})"
end

def find_latest_version(response)
latest_version = response.dig('dist-tags', 'latest')
return latest_version if latest_version

return unless response['versions']

response['versions'].keys.map { |v| Gem::Version.new(v) rescue nil }.compact.sort.last
end

def outdated?(current_version, latest_version)
Gem::Version.new(current_version) < Gem::Version.new(latest_version)
rescue ArgumentError
current_version.to_s < latest_version.to_s
end

def get_audit
uri = self.class.base_uri.dup
uri.path = "/-/npm/v1/security/advisories/bulk"

body = packages_with_versions.each.with_object({}) { |(package, version), data|
data[package] ||= []
data[package] << version
}
return {} if body.empty?

response = post_json(uri, body)
JSON.parse(response.body)
end

def post_json(uri, body)
Net::HTTP.post(uri, body.to_json, "Content-Type" => "application/json")
rescue => error
raise HTTPError, "Unexpected transport error (#{error.class}: #{error.message})"
end
end
1 change: 1 addition & 0 deletions test/fixtures/files/outdated_import_map.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pin "md5", to: "https://cdn.skypack.dev/md5@2.2.0", preload: true
1 change: 1 addition & 0 deletions test/fixtures/files/vulnerable_import_map.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pin "is-svg", to: "https://cdn.skypack.dev/is-svg@3.0.0", preload: true
67 changes: 67 additions & 0 deletions test/npm_integration_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
require "test_helper"
require "importmap/npm"

class Importmap::NpmIntegrationTest < ActiveSupport::TestCase
test "successful outdated packages against live service" do
file = file_fixture("outdated_import_map.rb")
npm = Importmap::Npm.new(file)

outdated_packages = npm.outdated_packages

assert_equal(1, outdated_packages.size)
assert_equal("md5", outdated_packages[0].name)
assert_equal("2.2.0", outdated_packages[0].current_version)
assert_match(/\d+\.\d+\.\d+/, outdated_packages[0].latest_version)
end

test "failed outdated packages request against live bad domain" do
file = file_fixture("outdated_import_map.rb")
npm = Importmap::Npm.new(file)

original_base_uri = Importmap::Npm.base_uri
Importmap::Npm.base_uri = URI("https://invalid.error")

assert_raises(Importmap::Npm::HTTPError) do
npm.outdated_packages
end
ensure
Importmap::Npm.base_uri = original_base_uri
end

test "successful vulnerable packages against live service" do
file = file_fixture("vulnerable_import_map.rb")
npm = Importmap::Npm.new(file)

vulnerable_packages = npm.vulnerable_packages

assert(vulnerable_packages.size >= 2)

assert_equal("is-svg", vulnerable_packages[0].name)
assert_equal("is-svg", vulnerable_packages[1].name)

severities = vulnerable_packages.map(&:severity)
assert_includes(severities, "high")

vulnerabilities = vulnerable_packages.map(&:vulnerability)
assert_includes(vulnerabilities, "ReDOS in IS-SVG")
assert_includes(vulnerabilities, "Regular Expression Denial of Service (ReDoS)")

vulnerable_versions = vulnerable_packages.map(&:vulnerable_versions)
assert_includes(vulnerable_versions, ">=2.1.0 <4.3.0")
assert_includes(vulnerable_versions, ">=2.1.0 <4.2.2")
end

test "failed vulnerable packages request against live bad domain" do
file = file_fixture("vulnerable_import_map.rb")
npm = Importmap::Npm.new(file)

original_base_uri = Importmap::Npm.base_uri
Importmap::Npm.base_uri = URI("https://invalid.error")

assert_raises(Importmap::Npm::HTTPError) do
npm.vulnerable_packages
end
ensure
Importmap::Npm.base_uri = original_base_uri
end
end
69 changes: 69 additions & 0 deletions test/npm_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
require "test_helper"
require "importmap/npm"
require "minitest/mock"

class Importmap::NpmTest < ActiveSupport::TestCase
setup { @npm = Importmap::Npm.new(file_fixture("outdated_import_map.rb")) }

test "successful outdated packages with mock" do
response = { "dist-tags" => { "latest" => '2.3.0' } }.to_json

@npm.stub(:get_json, response) do
outdated_packages = @npm.outdated_packages

assert_equal(1, outdated_packages.size)
assert_equal('md5', outdated_packages[0].name)
assert_equal('2.2.0', outdated_packages[0].current_version)
assert_equal('2.3.0', outdated_packages[0].latest_version)
end
end

test "missing outdated packages with mock" do
response = { "error" => "Not found" }.to_json

@npm.stub(:get_json, response) do
outdated_packages = @npm.outdated_packages

assert_equal(1, outdated_packages.size)
assert_equal('md5', outdated_packages[0].name)
assert_equal('2.2.0', outdated_packages[0].current_version)
assert_equal('Not found', outdated_packages[0].error)
end
end

test "failed outdated packages request with mock" do
Net::HTTP.stub(:get, proc { raise "Unexpected Error" }) do
assert_raises(Importmap::Npm::HTTPError) do
@npm.outdated_packages
end
end
end

test "successful vulnerable packages with mock" do
response = Class.new do
def body
{ "md5" => [{ "title" => "Unsafe hashing", "severity" => "high", "vulnerable_versions" => "<42.0.0" }] }.to_json
end

def code() "200" end
end.new

@npm.stub(:post_json, response) do
vulnerable_packages = @npm.vulnerable_packages

assert_equal(1, vulnerable_packages.size)
assert_equal('md5', vulnerable_packages[0].name)
assert_equal('Unsafe hashing', vulnerable_packages[0].vulnerability)
assert_equal('high', vulnerable_packages[0].severity)
assert_equal('<42.0.0', vulnerable_packages[0].vulnerable_versions)
end
end

test "failed vulnerable packages request with mock" do
Net::HTTP.stub(:post, proc { raise "Unexpected Error" }) do
assert_raises(Importmap::Npm::HTTPError) do
@npm.vulnerable_packages
end
end
end
end