Skip to content

Instantly share code, notes, and snippets.

@SamSaffron
Created November 25, 2025 04:47
Show Gist options
  • Select an option

  • Save SamSaffron/91a7f9a30ca90e75bceb572d0801ac87 to your computer and use it in GitHub Desktop.

Select an option

Save SamSaffron/91a7f9a30ca90e75bceb572d0801ac87 to your computer and use it in GitHub Desktop.
scan_files.rb
#!/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