diff --git a/lib/importmap/commands.rb b/lib/importmap/commands.rb index ea5025d..73e97ea 100644 --- a/lib/importmap/commands.rb +++ b/lib/importmap/commands.rb @@ -1,5 +1,6 @@ require "thor" require "importmap/packager" +require "importmap/npm" class Importmap::Commands < Thor include Thor::Actions @@ -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) @@ -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) diff --git a/lib/importmap/npm.rb b/lib/importmap/npm.rb new file mode 100644 index 0000000..a24bba5 --- /dev/null +++ b/lib/importmap/npm.rb @@ -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 diff --git a/test/fixtures/files/outdated_import_map.rb b/test/fixtures/files/outdated_import_map.rb new file mode 100644 index 0000000..450c589 --- /dev/null +++ b/test/fixtures/files/outdated_import_map.rb @@ -0,0 +1 @@ +pin "md5", to: "https://cdn.skypack.dev/md5@2.2.0", preload: true diff --git a/test/fixtures/files/vulnerable_import_map.rb b/test/fixtures/files/vulnerable_import_map.rb new file mode 100644 index 0000000..48e61a8 --- /dev/null +++ b/test/fixtures/files/vulnerable_import_map.rb @@ -0,0 +1 @@ +pin "is-svg", to: "https://cdn.skypack.dev/is-svg@3.0.0", preload: true diff --git a/test/npm_integration_test.rb b/test/npm_integration_test.rb new file mode 100644 index 0000000..2dcbe5f --- /dev/null +++ b/test/npm_integration_test.rb @@ -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 diff --git a/test/npm_test.rb b/test/npm_test.rb new file mode 100644 index 0000000..a60c636 --- /dev/null +++ b/test/npm_test.rb @@ -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