Last active
September 18, 2025 07:29
-
-
Save mcansky/5d50b47a86b04008d65eed4456922257 to your computer and use it in GitHub Desktop.
A set of scripts to check quickly impact of the 2025/09/08 supply chain attack on your project
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env ruby | |
| require 'json' | |
| require 'set' | |
| ##################################################################### | |
| ### Make sure you have a compromised_packages.json file with the list | |
| ##################################################################### | |
| class NPMSecurityChecker | |
| def initialize(compromised_json_file = 'compromised_packages.json') | |
| @compromised_json_file = compromised_json_file | |
| @compromised_packages = load_compromised_packages | |
| @findings = [] | |
| end | |
| def load_compromised_packages | |
| unless File.exist?(@compromised_json_file) | |
| puts "Error: #{@compromised_json_file} not found!" | |
| puts "Please ensure the JSON file with compromised packages exists." | |
| exit 1 | |
| end | |
| begin | |
| data = JSON.parse(File.read(@compromised_json_file)) | |
| packages = Set.new | |
| data['compromised_packages'].each do |package_version| | |
| packages.add(package_version) | |
| end | |
| packages | |
| rescue JSON::ParserError => e | |
| puts "Error parsing JSON file: #{e.message}" | |
| exit 1 | |
| rescue => e | |
| puts "Error loading compromised packages: #{e.message}" | |
| exit 1 | |
| end | |
| end | |
| def parse_package_version(package_version_string) | |
| # Handle scoped packages like @scope/package@version | |
| if package_version_string.start_with?('@') | |
| # Find the last @ which separates version from package name | |
| parts = package_version_string.split('@') | |
| if parts.length >= 3 | |
| # @scope/package@version -> ["", "scope/package", "version"] | |
| package_name = "@#{parts[1]}" | |
| version = parts[2] | |
| else | |
| return nil | |
| end | |
| else | |
| # Regular package@version | |
| parts = package_version_string.split('@') | |
| if parts.length == 2 | |
| package_name = parts[0] | |
| version = parts[1] | |
| else | |
| return nil | |
| end | |
| end | |
| [package_name, version] | |
| end | |
| def check_package_json(file_path) | |
| return unless File.exist?(file_path) | |
| begin | |
| data = JSON.parse(File.read(file_path)) | |
| rescue JSON::ParserError => e | |
| puts "Warning: Could not parse #{file_path}: #{e.message}" | |
| return | |
| end | |
| dependency_types = %w[dependencies devDependencies peerDependencies optionalDependencies] | |
| dependency_types.each do |dep_type| | |
| next unless data[dep_type] | |
| data[dep_type].each do |package_name, version_spec| | |
| # Clean version spec (remove ^, ~, etc.) | |
| clean_version = version_spec.gsub(/^[\^~>=<]/, '').split(' ').first | |
| # Check if this exact package@version is compromised | |
| package_version = "#{package_name}@#{clean_version}" | |
| if @compromised_packages.include?(package_version) | |
| @findings << { | |
| file: file_path, | |
| package: package_name, | |
| version: clean_version, | |
| version_spec: version_spec, | |
| dependency_type: dep_type, | |
| match_type: 'exact', | |
| source: 'package.json' | |
| } | |
| end | |
| # Also check if any compromised package has the same name (version mismatch) | |
| @compromised_packages.each do |compromised| | |
| comp_name, comp_version = parse_package_version(compromised) | |
| next unless comp_name | |
| if comp_name == package_name && comp_version != clean_version | |
| @findings << { | |
| file: file_path, | |
| package: package_name, | |
| version: clean_version, | |
| version_spec: version_spec, | |
| compromised_version: comp_version, | |
| dependency_type: dep_type, | |
| match_type: 'name_only', | |
| source: 'package.json' | |
| } | |
| end | |
| end | |
| end | |
| end | |
| end | |
| def check_package_lock_json(file_path) | |
| return unless File.exist?(file_path) | |
| begin | |
| data = JSON.parse(File.read(file_path)) | |
| rescue JSON::ParserError => e | |
| puts "Warning: Could not parse #{file_path}: #{e.message}" | |
| return | |
| end | |
| # Check packages section (npm v7+) | |
| if data['packages'] | |
| data['packages'].each do |package_path, package_info| | |
| next if package_path.empty? # Skip root | |
| package_name = package_info['name'] | |
| version = package_info['version'] | |
| next unless package_name && version | |
| package_version = "#{package_name}@#{version}" | |
| if @compromised_packages.include?(package_version) | |
| @findings << { | |
| file: file_path, | |
| package: package_name, | |
| version: version, | |
| match_type: 'exact', | |
| source: 'package-lock.json', | |
| package_path: package_path | |
| } | |
| end | |
| end | |
| end | |
| # Check dependencies section (npm v6 and earlier) | |
| if data['dependencies'] | |
| check_lock_dependencies(data['dependencies'], file_path) | |
| end | |
| end | |
| def check_lock_dependencies(deps, file_path, prefix = '') | |
| deps.each do |package_name, package_info| | |
| version = package_info['version'] | |
| next unless version | |
| package_version = "#{package_name}@#{version}" | |
| if @compromised_packages.include?(package_version) | |
| @findings << { | |
| file: file_path, | |
| package: package_name, | |
| version: version, | |
| match_type: 'exact', | |
| source: 'package-lock.json' | |
| } | |
| end | |
| # Recursively check nested dependencies | |
| if package_info['dependencies'] | |
| check_lock_dependencies(package_info['dependencies'], file_path, "#{prefix}#{package_name}/") | |
| end | |
| end | |
| end | |
| def check_yarn_lock(file_path) | |
| return unless File.exist?(file_path) | |
| begin | |
| content = File.read(file_path) | |
| # Parse yarn.lock entries | |
| content.scan(/^"?([^"\s]+)@[^"]*"?:\s*\n(?:\s+.*\n)*?\s+version\s+"([^"]+)"/) do |package_name, version| | |
| # Handle scoped packages in yarn.lock format | |
| if package_name.include?('@') && !package_name.start_with?('@') | |
| # Convert "package@^1.0.0" style to just "package" | |
| package_name = package_name.split('@').first | |
| end | |
| package_version = "#{package_name}@#{version}" | |
| if @compromised_packages.include?(package_version) | |
| @findings << { | |
| file: file_path, | |
| package: package_name, | |
| version: version, | |
| match_type: 'exact', | |
| source: 'yarn.lock' | |
| } | |
| end | |
| end | |
| rescue => e | |
| puts "Warning: Could not parse #{file_path}: #{e.message}" | |
| end | |
| end | |
| def scan_directory(dir_path) | |
| Dir.glob(File.join(dir_path, '**/package.json')) do |file| | |
| check_package_json(file) | |
| end | |
| Dir.glob(File.join(dir_path, '**/package-lock.json')) do |file| | |
| check_package_lock_json(file) | |
| end | |
| Dir.glob(File.join(dir_path, '**/yarn.lock')) do |file| | |
| check_yarn_lock(file) | |
| end | |
| end | |
| def scan_file(file_path) | |
| case File.basename(file_path) | |
| when 'package.json' | |
| check_package_json(file_path) | |
| when 'package-lock.json' | |
| check_package_lock_json(file_path) | |
| when 'yarn.lock' | |
| check_yarn_lock(file_path) | |
| else | |
| puts "Unsupported file type: #{file_path}" | |
| end | |
| end | |
| def report_findings | |
| puts "=" * 70 | |
| puts "NPM Supply Chain Security Check Results" | |
| puts "=" * 70 | |
| puts "Checked against #{@compromised_packages.size} known compromised packages" | |
| puts | |
| if @findings.empty? | |
| puts "✓ No compromised packages detected!" | |
| puts "Your dependencies appear safe from this known supply chain attack." | |
| return true | |
| end | |
| puts "⚠ SECURITY ALERT: Compromised packages detected!" | |
| puts | |
| # Group findings by file | |
| @findings.group_by { |f| f[:file] }.each do |file, findings| | |
| puts "File: #{file}" | |
| findings.each do |finding| | |
| case finding[:match_type] | |
| when 'exact' | |
| puts " 🚨 EXACT MATCH: #{finding[:package]}@#{finding[:version]}" | |
| when 'name_only' | |
| puts " ⚠ PACKAGE NAME MATCH: #{finding[:package]}" | |
| puts " Your version: #{finding[:version]} | Compromised version: #{finding[:compromised_version]}" | |
| end | |
| puts " Source: #{finding[:source]}" | |
| puts " Type: #{finding[:dependency_type]}" if finding[:dependency_type] | |
| puts | |
| end | |
| end | |
| puts "IMMEDIATE ACTIONS REQUIRED:" | |
| puts "1. Do NOT install or update these packages" | |
| puts "2. Remove these packages from your project immediately" | |
| puts "3. Clear package manager cache (npm cache clean --force)" | |
| puts "4. Review your systems for signs of compromise" | |
| puts "5. Consider rotating API keys and secrets" | |
| puts "6. Update your security team if applicable" | |
| false | |
| end | |
| def run(paths) | |
| paths = ['.'] if paths.empty? | |
| paths.each do |path| | |
| if File.directory?(path) | |
| puts "Scanning directory: #{File.expand_path(path)}" | |
| scan_directory(path) | |
| elsif File.file?(path) | |
| puts "Scanning file: #{File.expand_path(path)}" | |
| scan_file(path) | |
| else | |
| puts "Path not found: #{path}" | |
| end | |
| end | |
| safe = report_findings | |
| exit(safe ? 0 : 1) | |
| end | |
| end | |
| # Run the checker | |
| if __FILE__ == $0 | |
| checker = NPMSecurityChecker.new | |
| checker.run(ARGV) | |
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment