Created
November 25, 2025 04:47
-
-
Save SamSaffron/91a7f9a30ca90e75bceb572d0801ac87 to your computer and use it in GitHub Desktop.
scan_files.rb
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 | |
| # frozen_string_literal: true | |
| # Check JavaScript manifests/lockfiles for Wiz "Shai Hulud 2.0" compromised | |
| # packages. | |
| # | |
| # Fetches the official package list from Wiz's public IOC repo and scans every | |
| # `pnpm-lock.yaml`, `yarn.lock`, `package-lock.json`, `npm-shrinkwrap.json`, and | |
| # `package.json` under the repository. Reports any compromised packages found | |
| # along with the manifest/lockfile path. | |
| # | |
| # Usage: | |
| # ruby script/check_shai_hulud_packages.rb [--verbose] | |
| require "csv" | |
| require "json" | |
| require "open-uri" | |
| require "optparse" | |
| require "pathname" | |
| require "uri" | |
| IOC_URL = "https://raw.githubusercontent.com/wiz-sec-public/wiz-research-iocs/main/reports/shai-hulud-2-packages.csv" | |
| IGNORED_PATH_PARTS = ["node_modules"].freeze | |
| TARGET_PATTERNS = { | |
| "pnpm-lock.yaml" => :scan_text_lockfile, | |
| "yarn.lock" => :scan_text_lockfile, | |
| "package-lock.json" => :scan_package_lockfile, | |
| "npm-shrinkwrap.json" => :scan_package_lockfile, | |
| "package.json" => :scan_package_json, | |
| }.freeze | |
| def log(message, verbose) | |
| puts(message) if verbose | |
| end | |
| def fetch_impacted_packages(verbose: false, source: nil) | |
| if source | |
| log("Loading impacted packages from #{source}...", verbose) | |
| csv_data = File.read(source) | |
| else | |
| log("Fetching impacted packages from #{IOC_URL}...", verbose) | |
| csv_data = URI.parse(IOC_URL).open(read_timeout: 30, open_timeout: 30).read | |
| end | |
| csv = CSV.new(csv_data, headers: true) | |
| packages = [] | |
| csv.each do |row| | |
| pkg = row["Package"]&.strip | |
| packages << pkg if pkg && !pkg.empty? && !packages.include?(pkg) | |
| end | |
| log("Loaded #{packages.size} impacted packages", verbose) | |
| packages | |
| rescue StandardError => e | |
| warn "Failed to load IOC list: #{e.message}" | |
| exit 1 | |
| end | |
| def ignored_path?(path) | |
| path.each_filename.any? { |segment| IGNORED_PATH_PARTS.include?(segment) } | |
| end | |
| def target_files(root, verbose: false) | |
| files = {} | |
| TARGET_PATTERNS.each_key do |pattern| | |
| log("Searching for #{pattern} files under #{root}...", verbose) | |
| matches = | |
| Dir | |
| .glob("**/#{pattern}", File::FNM_DOTMATCH) | |
| .reject { |path| ignored_path?(Pathname(path)) } | |
| .sort | |
| .map { |p| Pathname(p) } | |
| log("Found #{matches.size} #{pattern} file(s)", verbose) | |
| files[pattern] = matches | |
| end | |
| files | |
| end | |
| def text_lockfile_match?(text, pkg) | |
| escaped = Regexp.escape(pkg) | |
| text.match?(%r{/(#{escaped})@}) || | |
| text.match?(/(?:^|\s|["'])#{escaped}@/) || | |
| text.match?(/name["']?:\s*["']#{escaped}["']/) | |
| end | |
| def scan_text_lockfile(lockfile, packages, verbose: false) | |
| log("Scanning #{lockfile}...", verbose) | |
| text = lockfile.read | |
| packages.select { |pkg| text_lockfile_match?(text, pkg) } | |
| end | |
| def extract_package_names_from_manifest(data) | |
| keys = %w[ | |
| dependencies | |
| devDependencies | |
| peerDependencies | |
| optionalDependencies | |
| bundledDependencies | |
| bundleDependencies | |
| ] | |
| keys.flat_map { |key| data[key].is_a?(Hash) ? data[key].keys : [] } | |
| end | |
| def scan_package_json(file, packages, verbose: false) | |
| log("Scanning #{file}...", verbose) | |
| data = JSON.parse(file.read) | |
| names = extract_package_names_from_manifest(data) | |
| names.select { |name| packages.include?(name) } | |
| rescue JSON::ParserError => e | |
| warn "Skipping #{file} due to invalid JSON: #{e.message}" | |
| [] | |
| end | |
| def package_name_from_path(path) | |
| parts = path.split("/").reject(&:empty?) | |
| return nil if parts.empty? | |
| last = parts.pop | |
| return last if last.start_with?("@") | |
| if parts.last&.start_with?("@") | |
| "#{parts.last}/#{last}" | |
| else | |
| last | |
| end | |
| end | |
| def package_names_from_lockfile(data) | |
| names = [] | |
| if data["packages"].is_a?(Hash) | |
| data["packages"].each_key do |pkg_path| | |
| next if pkg_path.nil? || pkg_path.empty? | |
| name = package_name_from_path(pkg_path) | |
| names << name if name | |
| end | |
| end | |
| if data["dependencies"].is_a?(Hash) | |
| names.concat(data["dependencies"].keys) | |
| end | |
| names | |
| end | |
| def scan_package_lockfile(file, packages, verbose: false) | |
| log("Scanning #{file}...", verbose) | |
| data = JSON.parse(file.read) | |
| names = package_names_from_lockfile(data) | |
| names.select { |name| packages.include?(name) } | |
| rescue JSON::ParserError => e | |
| warn "Skipping #{file} due to invalid JSON: #{e.message}" | |
| [] | |
| end | |
| def scan_target_file(type, file, packages, verbose: false) | |
| case type | |
| when "package.json" | |
| scan_package_json(file, packages, verbose: verbose) | |
| when "package-lock.json", "npm-shrinkwrap.json" | |
| scan_package_lockfile(file, packages, verbose: verbose) | |
| else | |
| scan_text_lockfile(file, packages, verbose: verbose) | |
| end | |
| end | |
| def main(argv) | |
| verbose = false | |
| packages_file = ENV["SHAI_HULUD_PACKAGES_PATH"] | |
| OptionParser.new do |opts| | |
| opts.banner = "Usage: ruby script/check_shai_hulud_packages.rb [options]" | |
| opts.on("-v", "--verbose", "Show progress information") { verbose = true } | |
| opts.on("-fFILE", "--packages-file=FILE", "Use a local CSV instead of downloading") do |file| | |
| packages_file = file | |
| end | |
| end.parse!(argv) | |
| packages = fetch_impacted_packages(verbose: verbose, source: packages_file) | |
| files_by_type = target_files(Pathname.pwd, verbose: verbose) | |
| targets = files_by_type.values.flatten | |
| if targets.empty? | |
| puts "No package manifests or lockfiles found." | |
| return 0 | |
| end | |
| compromised_found = false | |
| files_by_type.each do |type, files| | |
| files.each do |file| | |
| hits = scan_target_file(type, file, packages, verbose: verbose) | |
| next if hits.empty? | |
| compromised_found = true | |
| puts "[ALERT] #{file} references compromised packages:" \ | |
| "\n - #{hits.to_a.uniq.sort.join("\n - ")}" | |
| end | |
| end | |
| puts "No compromised packages found in any package manifest or lockfile." unless compromised_found | |
| 0 | |
| end | |
| exit(main(ARGV)) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment