Created
March 31, 2026 14:09
-
-
Save anton-yurchenko/e50a47f6831867cc3c83bfbfcbc931a4 to your computer and use it in GitHub Desktop.
Scan GitHub repositories for specific NPM package usage
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 bash | |
| set -euo pipefail | |
| # Scan a GitHub organization for npm packages | |
| # Usage: ./scan.sh <org> <github-token> <pkg[@version]> [<pkg[@version]> ...] | |
| # | |
| # Examples: | |
| # ./scan.sh MyOrg ghp_xxx axios@1.14.1 axios@0.30.4 plain-crypto-js@4.2.1 | |
| # ./scan.sh MyOrg ghp_xxx plain-crypto-js # any version | |
| usage() { | |
| echo "Usage: $0 <org> <github-token> <pkg[@version]> [<pkg[@version]> ...]" | |
| echo "" | |
| echo " pkg@version — match exact version (e.g. axios@1.14.1)" | |
| echo " pkg — flag any occurrence regardless of version (e.g. plain-crypto-js)" | |
| exit 1 | |
| } | |
| [[ $# -ge 3 ]] || usage | |
| ORG="$1"; shift | |
| TOKEN="$1"; shift | |
| PACKAGES=("$@") | |
| FOUND=() | |
| gh_api() { | |
| curl -s -H "Authorization: token ${TOKEN}" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| "$@" | |
| } | |
| code_search() { | |
| local query="$1" | |
| local page=1 | |
| local per_page=100 | |
| while :; do | |
| local response | |
| response=$(gh_api "https://api.github.com/search/code?q=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$query'))")&per_page=${per_page}&page=${page}") | |
| local count | |
| count=$(echo "$response" | jq '.items | length') | |
| if [[ "$count" == "null" || "$count" == "0" ]]; then | |
| break | |
| fi | |
| echo "$response" | jq -r '.items[] | "\(.repository.full_name) \(.path) \(.url)"' | |
| if [[ "$count" -lt "$per_page" ]]; then | |
| break | |
| fi | |
| ((page++)) | |
| sleep 2 | |
| done | |
| } | |
| # Check a file for an exact version match. Returns 0 if found. | |
| check_file_for_version() { | |
| local contents_url="$1" | |
| local pkg_name="$2" | |
| local pkg_version="$3" | |
| local file_path="$4" | |
| local content | |
| content=$(gh_api -H "Accept: application/vnd.github.raw+json" "$contents_url" 2>/dev/null || true) | |
| if [[ -z "$content" ]]; then | |
| return 1 | |
| fi | |
| if echo "$file_path" | grep -q "package-lock"; then | |
| if echo "$content" | python3 -c " | |
| import sys, json | |
| try: | |
| lock = json.load(sys.stdin) | |
| except: | |
| sys.exit(1) | |
| pkg = '${pkg_name}' | |
| ver = '${pkg_version}' | |
| for key, val in lock.get('packages', {}).items(): | |
| if key.endswith('/' + pkg) or key == pkg: | |
| if val.get('version') == ver: | |
| print(f'MATCH in packages: {key} -> {ver}') | |
| sys.exit(0) | |
| def check_deps(deps, path=''): | |
| for name, info in deps.items(): | |
| if name == pkg and info.get('version') == ver: | |
| print(f'MATCH in dependencies: {path}{name} -> {ver}') | |
| sys.exit(0) | |
| if 'dependencies' in info: | |
| check_deps(info['dependencies'], path + name + '/') | |
| check_deps(lock.get('dependencies', {})) | |
| sys.exit(1) | |
| " 2>/dev/null; then | |
| return 0 | |
| fi | |
| else | |
| if echo "$content" | python3 -c " | |
| import sys, json | |
| try: | |
| pkg = json.load(sys.stdin) | |
| except: | |
| sys.exit(1) | |
| name = '${pkg_name}' | |
| ver = '${pkg_version}' | |
| for section in ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']: | |
| deps = pkg.get(section, {}) | |
| if name in deps: | |
| declared = deps[name].lstrip('^~>=<! ') | |
| if declared == ver: | |
| print(f'MATCH in {section}: {name} -> {deps[name]}') | |
| sys.exit(0) | |
| sys.exit(1) | |
| " 2>/dev/null; then | |
| return 0 | |
| fi | |
| fi | |
| return 1 | |
| } | |
| # Check whether a file simply contains the package name (any version). | |
| check_file_for_presence() { | |
| local contents_url="$1" | |
| local pkg_name="$2" | |
| local file_path="$3" | |
| local content | |
| content=$(gh_api -H "Accept: application/vnd.github.raw+json" "$contents_url" 2>/dev/null || true) | |
| if [[ -z "$content" ]]; then | |
| return 1 | |
| fi | |
| if echo "$file_path" | grep -q "package-lock"; then | |
| if echo "$content" | python3 -c " | |
| import sys, json | |
| try: | |
| lock = json.load(sys.stdin) | |
| except: | |
| sys.exit(1) | |
| pkg = '${pkg_name}' | |
| for key, val in lock.get('packages', {}).items(): | |
| if key.endswith('/' + pkg) or key == pkg: | |
| print(f'FOUND in packages: {key} -> {val.get(\"version\", \"?\")}') | |
| sys.exit(0) | |
| def check_deps(deps, path=''): | |
| for name, info in deps.items(): | |
| if name == pkg: | |
| print(f'FOUND in dependencies: {path}{name} -> {info.get(\"version\", \"?\")}') | |
| sys.exit(0) | |
| if 'dependencies' in info: | |
| check_deps(info['dependencies'], path + name + '/') | |
| check_deps(lock.get('dependencies', {})) | |
| sys.exit(1) | |
| " 2>/dev/null; then | |
| return 0 | |
| fi | |
| else | |
| if echo "$content" | python3 -c " | |
| import sys, json | |
| try: | |
| pkg = json.load(sys.stdin) | |
| except: | |
| sys.exit(1) | |
| name = '${pkg_name}' | |
| for section in ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']: | |
| deps = pkg.get(section, {}) | |
| if name in deps: | |
| print(f'FOUND in {section}: {name} -> {deps[name]}') | |
| sys.exit(0) | |
| sys.exit(1) | |
| " 2>/dev/null; then | |
| return 0 | |
| fi | |
| fi | |
| return 1 | |
| } | |
| echo "============================================" | |
| echo " npm package scanner" | |
| echo " Org: ${ORG}" | |
| echo " Date: $(date -u '+%Y-%m-%d %H:%M UTC')" | |
| echo "============================================" | |
| echo "" | |
| echo "Scanning for:" | |
| for p in "${PACKAGES[@]}"; do | |
| if [[ "$p" == *@* ]]; then | |
| echo " - ${p} (exact version)" | |
| else | |
| echo " - ${p} (any version)" | |
| fi | |
| done | |
| echo "" | |
| declare -A CHECKED_FILES=() | |
| for spec in "${PACKAGES[@]}"; do | |
| if [[ "$spec" == *@* ]]; then | |
| pkg_name="${spec%@*}" | |
| pkg_version="${spec#*@}" | |
| mode="version" | |
| else | |
| pkg_name="$spec" | |
| pkg_version="" | |
| mode="any" | |
| fi | |
| if [[ "$mode" == "version" ]]; then | |
| echo "--- Searching for ${pkg_name} (target version: ${pkg_version}) ---" | |
| else | |
| echo "--- Searching for ${pkg_name} (any version) ---" | |
| fi | |
| for filename in "package.json" "package-lock.json"; do | |
| echo " Searching in ${filename}..." | |
| results=$(code_search "org:${ORG} filename:${filename} \"${pkg_name}\"" 2>/dev/null || true) | |
| if [[ -z "$results" ]]; then | |
| echo " No results from code search." | |
| continue | |
| fi | |
| while IFS= read -r line; do | |
| repo=$(echo "$line" | awk '{print $1}') | |
| fpath=$(echo "$line" | awk '{print $2}') | |
| file_key="${repo}:${fpath}:${spec}" | |
| if [[ -n "${CHECKED_FILES[$file_key]+x}" ]]; then | |
| continue | |
| fi | |
| CHECKED_FILES[$file_key]=1 | |
| contents_url="https://api.github.com/repos/${repo}/contents/${fpath}" | |
| echo -n " Checking ${repo} / ${fpath} ... " | |
| if [[ "$mode" == "version" ]]; then | |
| if check_file_for_version "$contents_url" "$pkg_name" "$pkg_version" "$fpath"; then | |
| echo "VULNERABLE!" | |
| FOUND+=("${repo} | ${fpath} | ${spec}") | |
| else | |
| echo "ok (different version)" | |
| fi | |
| else | |
| if check_file_for_presence "$contents_url" "$pkg_name" "$fpath"; then | |
| echo "FOUND!" | |
| FOUND+=("${repo} | ${fpath} | ${pkg_name} (any version)") | |
| else | |
| echo "not present" | |
| fi | |
| fi | |
| sleep 0.5 | |
| done <<< "$results" | |
| done | |
| # For "any version" mode, also do a broad search outside package*.json | |
| if [[ "$mode" == "any" ]]; then | |
| echo " Broad search (all files)..." | |
| results=$(code_search "org:${ORG} \"${pkg_name}\" NOT filename:package.json NOT filename:package-lock.json" 2>/dev/null || true) | |
| if [[ -n "$results" ]]; then | |
| while IFS= read -r line; do | |
| repo=$(echo "$line" | awk '{print $1}') | |
| fpath=$(echo "$line" | awk '{print $2}') | |
| file_key="${repo}:${fpath}:${spec}" | |
| if [[ -n "${CHECKED_FILES[$file_key]+x}" ]]; then | |
| continue | |
| fi | |
| CHECKED_FILES[$file_key]=1 | |
| echo " WARNING: ${pkg_name} referenced in ${repo} / ${fpath}" | |
| FOUND+=("${repo} | ${fpath} | ${pkg_name} (ref in non-package file)") | |
| done <<< "$results" | |
| else | |
| echo " No additional references found." | |
| fi | |
| fi | |
| echo "" | |
| done | |
| echo "============================================" | |
| echo " RESULTS" | |
| echo "============================================" | |
| if [[ ${#FOUND[@]} -eq 0 ]]; then | |
| echo "" | |
| echo " No affected repositories found in ${ORG}." | |
| echo "" | |
| else | |
| echo "" | |
| echo " AFFECTED REPOSITORIES:" | |
| echo "" | |
| printf " %-40s | %-30s | %s\n" "REPOSITORY" "FILE" "PACKAGE" | |
| printf " %-40s-+-%-30s-+-%s\n" "----------------------------------------" "------------------------------" "-------------------------" | |
| for entry in "${FOUND[@]}"; do | |
| printf " %s\n" "$entry" | |
| done | |
| echo "" | |
| echo " Total: ${#FOUND[@]} finding(s)" | |
| echo "" | |
| fi | |
| echo "Scan complete." |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment