Skip to content

Instantly share code, notes, and snippets.

@ivo-toby
Forked from Lp-Francois/README.md
Last active November 25, 2025 10:28
Show Gist options
  • Select an option

  • Save ivo-toby/3c11aa343f6354fd7089a3d4a958f1cd to your computer and use it in GitHub Desktop.

Select an option

Save ivo-toby/3c11aa343f6354fd7089a3d4a958f1cd to your computer and use it in GitHub Desktop.
Check all repositories in a GitHub organization for SHA1Hulud vulnerabilities by analyzing lock files via GitHub API (no cloning required).

SHA1Hulud Vulnerability Checker

Check all repositories in a GitHub organization or user account for SHA1Hulud vulnerabilities by analyzing lock files via GitHub API (no cloning required).

Usage

For organizations:

export GITHUB_ACCOUNT=myorg
export GITHUB_TOKEN=ghp_xxx
node check-github-org-sha1hulud.js

For user accounts:

export GITHUB_ACCOUNT=myuser
export GITHUB_ACCOUNT_TYPE=user
export GITHUB_TOKEN=ghp_xxx
node check-github-org-sha1hulud.js

Options

  • GITHUB_ACCOUNT - GitHub organization or user name (required)
  • GITHUB_ACCOUNT_TYPE - Type of account: org (default) or user
  • GITHUB_ORG - Alias for GITHUB_ACCOUNT (backwards compatibility)
  • BATCH_SIZE - Number of repos to check in parallel (default: 10)
  • DEBUG - Enable debug logging (set to true)
  • CLEAR_CACHE=true - Clear cache before running (could also use --clear-cache or -c)

Requirements

  • Node.js (v12+)
  • GitHub token with repo scope

What is SHA1Hulud?

Supply chain attack affecting hundreds of npm packages. See HelixGuard Blog for details.

Exit Codes

  • 0 - No vulnerabilities found
  • 1 - Vulnerabilities found
#!/usr/bin/env node
/**
* GitHub Organization SHA1Hulud Vulnerability Checker
*
* Checks all repositories in a GitHub organization or user account for SHA1Hulud vulnerabilities
* by fetching lock files directly via GitHub API (no cloning required)
*
* Usage: GITHUB_ACCOUNT=myorg GITHUB_TOKEN=ghp_xxx node tools/check-github-org-sha1hulud.js
* GITHUB_ACCOUNT=myuser GITHUB_ACCOUNT_TYPE=user GITHUB_TOKEN=ghp_xxx node tools/check-github-org-sha1hulud.js
*
* Command-line flags:
* --clear-cache, -c - Clear the cache before running
* --clear-cache-only, -C - Clear the cache and exit (no check performed)
*
* Environment Variables:
* GITHUB_ACCOUNT - GitHub organization or user name (required)
* GITHUB_ORG - Alias for GITHUB_ACCOUNT (for backwards compatibility)
* GITHUB_ACCOUNT_TYPE - Type of account: 'org' (default) or 'user'
* GITHUB_TOKEN - GitHub personal access token (required)
* BATCH_SIZE - Number of repositories to check in parallel (default: 10)
* DEBUG - Enable debug logging (set to 'true' to enable)
* CLEAR_CACHE - Clear cache before running (set to 'true')
*/
const https = require("https");
const fs = require("fs");
const path = require("path");
const os = require("os");
// Debug mode - set DEBUG=true to enable verbose logging
const DEBUG = process.env.DEBUG === "true";
// Cache configuration
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
const CACHE_FILE = path.join(os.homedir(), ".sha1hulud-cache.json");
/**
* Debug logging function
*/
function debugLog(...args) {
if (DEBUG) {
console.log("[DEBUG]", ...args);
}
}
// Vulnerable packages and versions from the blog post
// (Same as check-sha1hulud-vuln.js)
const VULNERABLE_PACKAGES = {
"@zapier/zapier-sdk": ["0.15.5", "0.15.7"],
"@posthog/core": ["1.5.6"],
"posthog-node": ["5.11.3", "5.13.3", "4.18.1"],
"@asyncapi/specs": ["6.10.1", "6.8.2", "6.9.1", "6.8.3"],
"@postman/tunnel-agent": ["0.6.6", "0.6.5"],
"posthog-react-native": ["4.12.5", "4.11.1"],
"@asyncapi/parser": ["3.4.1", "3.4.2"],
"@asyncapi/openapi-schema-parser": ["3.0.25"],
"@asyncapi/avro-schema-parser": ["3.0.25", "3.0.26"],
"@asyncapi/protobuf-schema-parser": ["3.6.1", "3.5.3"],
"@asyncapi/react-component": ["2.6.6"],
"@asyncapi/generator": ["2.8.5"],
"@posthog/ai": ["7.1.2"],
"@asyncapi/modelina": ["5.10.2", "5.10.3"],
"@asyncapi/generator-react-sdk": ["1.1.4", "1.1.5"],
"@postman/csv-parse": ["4.0.3", "4.0.4", "4.0.5"],
"posthog-react-native-session-replay": ["1.2.2"],
"@asyncapi/converter": ["1.6.3"],
"@asyncapi/multi-parser": ["2.2.1", "2.2.2"],
"@posthog/cli": ["0.5.15"],
"@zapier/secret-scrubber": ["1.1.3", "1.1.4", "1.1.5"],
"zapier-platform-schema": ["18.0.2"],
"zapier-platform-core": ["18.0.2", "18.0.3"],
"@ensdomains/address-encoder": ["1.1.5"],
"@ensdomains/content-hash": ["3.0.1"],
"crypto-addr-codec": ["0.1.9"],
"@asyncapi/nunjucks-filters": ["2.1.1", "2.1.2"],
"@asyncapi/bundler": ["0.6.5", "0.6.6"],
"@posthog/nextjs-config": ["1.5.1"],
"@asyncapi/html-template": ["3.3.2", "3.3.3"],
"@asyncapi/diff": ["0.5.1", "0.5.2"],
"@asyncapi/cli": ["4.1.2"],
"@asyncapi/optimizer": ["1.0.5", "1.0.6"],
"@asyncapi/modelina-cli": ["5.10.2", "5.10.3"],
"@postman/aether-icons": ["2.23.2", "2.23.4"],
"@asyncapi/generator-components": ["0.3.2"],
"@asyncapi/generator-helpers": ["0.2.1", "0.2.2"],
"zapier-platform-cli": ["18.0.3"],
"@posthog/rrweb": ["0.0.31"],
"ethereum-ens": ["0.8.1"],
"@posthog/rrweb-utils": ["0.0.31"],
"@posthog/rrweb-snapshot": ["0.0.31"],
"@posthog/rrdom": ["0.0.31"],
"@asyncapi/problem": ["1.0.1", "1.0.2"],
"@postman/secret-scanner-wasm": ["2.1.3", "2.1.2", "2.1.4"],
"@ensdomains/eth-ens-namehash": ["2.0.16"],
"posthog-docusaurus": ["2.0.6"],
"@postman/pretty-ms": ["6.1.1", "6.1.3", "6.1.2"],
"web-types-lit": ["0.1.1"],
"mcp-use": ["1.4.2", "1.4.3"],
"@posthog/react-rrweb-player": ["1.1.4"],
"@asyncapi/markdown-template": ["1.6.8", "1.6.9"],
"@ensdomains/buffer": ["0.1.2"],
"@postman/node-keytar": ["7.9.4", "7.9.5", "7.9.6"],
"@mcp-use/inspector": ["0.6.2", "0.6.3"],
"@mcp-use/cli": ["2.2.6"],
"@zapier/spectral-api-ruleset": ["1.9.1", "1.9.2", "1.9.3"],
"@posthog/geoip-plugin": ["0.0.8"],
"@ensdomains/dnsprovejs": ["0.5.3"],
"@ensdomains/solsha1": ["0.0.4"],
"@asyncapi/web-component": ["2.6.6", "2.6.7"],
"@posthog/nuxt": ["1.2.9"],
"@zapier/browserslist-config-zapier": ["1.0.3", "1.0.5"],
"@posthog/wizard": ["1.18.1"],
"react-native-use-modal": ["1.0.3"],
"@asyncapi/java-spring-template": ["1.6.1", "1.6.2"],
"@posthog/rrweb-record": ["0.0.31"],
"@posthog/siphash": ["1.1.2"],
"@posthog/piscina": ["3.2.1"],
"@ensdomains/ens-validation": ["0.1.1"],
"@posthog/plugin-contrib": ["0.0.6"],
"@posthog/agent": ["1.24.1"],
"@postman/postman-mcp-server": ["2.4.11", "2.4.10"],
"@asyncapi/nodejs-ws-template": ["0.10.1", "0.10.2"],
"@actbase/react-daum-postcode": ["1.0.5"],
"token.js-fork": ["0.7.32"],
"@postman/pm-bin-windows-x64": ["1.24.5", "1.24.4"],
"@ensdomains/ens-avatar": ["1.0.4"],
"@postman/pm-bin-linux-x64": ["1.24.3", "1.24.4", "1.24.5"],
"@posthog/hedgehog-mode": ["0.0.42"],
"create-mcp-use-app": ["0.5.3", "0.5.4"],
"@postman/pm-bin-macos-arm64": ["1.24.5", "1.24.3", "1.24.4"],
"@posthog/nextjs": ["0.0.3"],
"@postman/pm-bin-macos-x64": ["1.24.3", "1.24.5"],
"redux-router-kit": ["1.2.2", "1.2.3", "1.2.4"],
"@ensdomains/dnssecoraclejs": ["0.2.9"],
"@postman/mcp-ui-client": ["5.5.1", "5.5.2"],
"@postman/postman-mcp-cli": ["1.0.5", "1.0.4"],
"@zapier/babel-preset-zapier": ["6.4.1", "6.4.3"],
"@ensdomains/thorin": ["0.6.51"],
"@postman/postman-collection-fork": ["4.3.3", "4.3.4", "4.3.5"],
"@asyncapi/nodejs-template": ["3.0.5"],
"@postman/wdio-allure-reporter": ["0.0.9"],
"@postman/wdio-junit-reporter": ["0.0.4", "0.0.6"],
"@postman/final-node-keytar": ["7.9.1", "7.9.2"],
"zapier-async-storage": ["1.0.1", "1.0.2", "1.0.3"],
"@ensdomains/test-utils": ["1.3.1"],
"@ensdomains/hardhat-chai-matchers-viem": ["0.1.15"],
"@asyncapi/java-spring-cloud-stream-template": ["0.13.5", "0.13.6"],
"@zapier/eslint-plugin-zapier": ["11.0.3", "11.0.4", "11.0.5"],
"devstart-cli": ["1.0.6"],
"@asyncapi/java-template": ["0.3.5", "0.3.6"],
"@asyncapi/go-watermill-template": ["0.2.76", "0.2.77"],
"@asyncapi/python-paho-template": ["0.2.14", "0.2.15"],
"@ensdomains/hardhat-toolbox-viem-extended": ["0.0.6"],
"@ensdomains/vite-plugin-i18next-loader": ["4.0.4"],
"zapier-platform-legacy-scripting-runner": ["4.0.3", "4.0.4"],
"@asyncapi/server-api": ["0.16.25"],
"@ensdomains/offchain-resolver-contracts": ["0.2.2"],
"@zapier/ai-actions": ["0.1.18", "0.1.19", "0.1.20"],
"@zapier/mcp-integration": ["3.0.1", "3.0.3"],
"@ensdomains/ens-archived-contracts": ["0.0.3"],
"@ensdomains/dnssec-oracle-anchors": ["0.0.2"],
"@ensdomains/mock": ["2.1.52"],
"zapier-scripts": ["7.8.3", "7.8.4"],
"@quick-start-soft/quick-task-refine": ["1.4.2511142126"],
"@zapier/ai-actions-react": ["0.1.13", "0.1.14"],
"@quick-start-soft/quick-git-clean-markdown": ["1.4.2511142126"],
"@ensdomains/ui": ["3.4.6"],
"@quick-start-soft/quick-markdown": ["1.4.2511142126"],
"@zapier/stubtree": ["0.1.3"],
"@ensdomains/unruggable-gateways": ["0.0.3"],
"@posthog/rrweb-player": ["0.0.31"],
"@asyncapi/dotnet-rabbitmq-template": ["1.0.1", "1.0.2"],
"@ensdomains/react-ens-address": ["0.0.32"],
"@asyncapi/php-template": ["0.1.1"],
"@quick-start-soft/quick-document-translator": ["1.4.2511142126"],
"@quick-start-soft/quick-markdown-image": ["1.4.2511142126"],
"@strapbuild/react-native-date-time-picker": ["2.0.4"],
"github-action-for-generator": ["2.1.28"],
"@actbase/react-kakaosdk": ["0.9.27"],
"bytecode-checker-cli": ["1.0.8", "1.0.9", "1.0.10"],
"@markvivanco/app-version-checker": ["1.0.1", "1.0.2"],
"@louisle2/cortex-js": ["0.1.6"],
"orbit-boxicons": ["2.1.3"],
"react-native-worklet-functions": ["3.3.3"],
"poper-react-sdk": ["0.1.2"],
"@ensdomains/web3modal": ["1.10.2"],
"gate-evm-tools-test": ["1.0.5", "1.0.6", "1.0.7"],
"n8n-nodes-tmdb": ["0.5.1"],
"capacitor-plugin-purchase": ["0.1.1"],
"expo-audio-session": ["0.2.1"],
"capacitor-plugin-apptrackingios": ["0.0.21"],
"asyncapi-preview": ["1.0.1", "1.0.2"],
"@actbase/react-absolute": ["0.8.3"],
"@actbase/react-native-devtools": ["0.1.3"],
"@posthog/variance-plugin": ["0.0.8"],
"@posthog/twitter-followers-plugin": ["0.0.8"],
"medusa-plugin-momo": ["0.0.68"],
"scgs-capacitor-subscribe": ["1.0.11"],
"gate-evm-check-code2": ["2.0.3", "2.0.4", "2.0.5"],
"lite-serper-mcp-server": ["0.2.2"],
"@asyncapi/edavisualiser": ["1.2.1", "1.2.2"],
"esbuild-plugin-eta": ["0.1.1"],
"@ensdomains/server-analytics": ["0.0.2"],
"zuper-stream": ["2.0.9"],
"@quick-start-soft/quick-markdown-compose": ["1.4.2506300029"],
"@posthog/snowflake-export-plugin": ["0.0.8"],
"@actbase/react-native-kakao-channel": ["1.0.2"],
"@posthog/sendgrid-plugin": ["0.0.8"],
"evm-checkcode-cli": ["1.0.12", "1.0.13", "1.0.14"],
"@ensdomains/subdomain-registrar": ["0.2.4"],
"claude-token-updater": ["1.0.3"],
"@trigo/atrix-pubsub": ["4.0.3"],
"@trigo/hapi-auth-signedlink": ["1.3.1"],
"@strapbuild/react-native-perspective-image-cropper-poojan31": ["0.4.6"],
"axios-builder": ["1.2.1"],
"calc-loan-interest": ["1.0.4"],
"medusa-plugin-announcement": ["0.0.3"],
open2internet: ["0.1.1"],
"@ensdomains/cypress-metamask": ["1.2.1"],
"@ensdomains/renewal": ["0.0.13"],
"cpu-instructions": ["0.0.14"],
"orbit-soap": ["0.43.13"],
"@asyncapi/keeper": ["0.0.2", "0.0.3"],
"@strapbuild/react-native-perspective-image-cropper-2": ["0.4.7"],
"@actbase/react-native-actionsheet": ["1.0.3"],
"@posthog/ingestion-alert-plugin": ["0.0.8"],
"@actbase/react-native-simple-video": ["1.0.13"],
"@actbase/react-native-kakao-navi": ["2.0.4"],
"medusa-plugin-zalopay": ["0.0.40"],
"@kvytech/medusa-plugin-newsletter": ["0.0.5"],
"@posthog/databricks-plugin": ["0.0.8"],
"capacitor-voice-recorder-wav": ["6.0.3"],
"create-hardhat3-app": ["1.1.1", "1.1.2"],
"rollup-plugin-httpfile": ["0.2.1"],
"@ensdomains/name-wrapper": ["1.0.1"],
"test-foundry-app": ["1.0.3"],
"jan-browser": ["0.13.1"],
"@mparpaillon/page": ["1.0.1"],
"go-template": ["0.1.8"],
"@strapbuild/react-native-perspective-image-cropper": ["0.4.15"],
"manual-billing-system-miniapp-api": ["1.3.1"],
"korea-administrative-area-geo-json-util": ["1.0.7"],
"@posthog/currency-normalization-plugin": ["0.0.8"],
"@posthog/web-dev-server": ["1.0.5"],
"@posthog/pagerduty-plugin": ["0.0.8"],
"@posthog/event-sequence-timer-plugin": ["0.0.8"],
"@posthog/automatic-cohorts-plugin": ["0.0.8"],
"@posthog/first-time-event-tracker": ["0.0.8"],
"@actbase/css-to-react-native-transform": ["1.0.3"],
"@posthog/url-normalizer-plugin": ["0.0.8"],
"@posthog/twilio-plugin": ["0.0.8"],
"@actbase/node-server": ["1.1.19"],
"@posthog/gitub-star-sync-plugin": ["0.0.8"],
"@seung-ju/react-native-action-sheet": ["0.2.1"],
"@posthog/maxmind-plugin": ["0.1.6"],
"@posthog/github-release-tracking-plugin": ["0.0.8"],
"@actbase/react-native-fast-image": ["8.5.13"],
"@posthog/customerio-plugin": ["0.0.8"],
"@posthog/kinesis-plugin": ["0.0.8"],
"@actbase/react-native-less-transformer": ["1.0.6"],
"@posthog/taxonomy-plugin": ["0.0.8"],
"medusa-plugin-product-reviews-kvy": ["0.0.4"],
"@aryanhussain/my-angular-lib": ["0.0.23"],
"dotnet-template": ["0.0.4"],
"capacitor-plugin-scgssigninwithgoogle": ["0.0.5"],
"capacitor-purchase-history": ["0.0.10"],
"@posthog/plugin-unduplicates": ["0.0.8"],
"posthog-plugin-hello-world": ["1.0.1"],
"esbuild-plugin-httpfile": ["0.4.1"],
"@ensdomains/blacklist": ["1.0.1"],
"@ensdomains/renewal-widget": ["0.1.10"],
"@ensdomains/hackathon-registrar": ["1.0.5"],
"@ensdomains/ccip-read-router": ["0.0.7"],
"@mcp-use/mcp-use": ["1.0.1"],
"test-hardhat-app": ["1.0.3"],
"zuper-cli": ["1.0.1"],
"skills-use": ["0.1.2"],
"typeorm-orbit": ["0.2.27"],
"orbit-nebula-editor": ["1.0.2"],
"@trigo/atrix-elasticsearch": ["2.0.1"],
"@trigo/atrix-soap": ["1.0.2"],
"eslint-config-zeallat-base": ["1.0.4"],
"iron-shield-miniapp": ["0.0.2"],
"shinhan-limit-scrap": ["1.0.3"],
"create-glee-app": ["0.2.3"],
"@seung-ju/next": ["0.0.2"],
"@actbase/react-native-tiktok": ["1.1.3"],
"discord-bot-server": ["0.1.2"],
"@seung-ju/openapi-generator": ["0.0.4"],
"@seung-ju/react-hooks": ["0.0.2"],
"@actbase/react-native-naver-login": ["1.0.1"],
"@kvytech/medusa-plugin-announcement": ["0.0.8"],
"@kvytech/components": ["0.0.2"],
"@kvytech/cli": ["0.0.7"],
"@kvytech/medusa-plugin-management": ["0.0.5"],
"@kvytech/medusa-plugin-product-reviews": ["0.0.9"],
"@kvytech/web": ["0.0.2"],
scgsffcreator: ["1.0.5"],
"vite-plugin-httpfile": ["0.2.1"],
"@ensdomains/curvearithmetics": ["1.0.1"],
"@ensdomains/reverse-records": ["1.0.1"],
"@ensdomains/ccip-read-dns-gateway": ["0.1.1"],
"@ensdomains/unicode-confusables": ["0.1.1"],
"@ensdomains/durin-middleware": ["0.0.2"],
"@ensdomains/ccip-read-worker-viem": ["0.0.4"],
atrix: ["1.0.1"],
"@caretive/caret-cli": ["0.0.2"],
"exact-ticker": ["0.3.5"],
"@orbitgtbelgium/orbit-components": ["1.2.9"],
"react-library-setup": ["0.0.6"],
"@orbitgtbelgium/mapbox-gl-draw-scale-rotate-mode": ["1.1.1"],
"orbit-nebula-draw-tools": ["1.0.10"],
"@orbitgtbelgium/time-slider": ["1.0.187"],
"react-element-prompt-inspector": ["0.1.18"],
"@trigo/pathfinder-ui-css": ["0.1.1"],
"eslint-config-trigo": ["22.0.2"],
"@trigo/fsm": ["3.4.2"],
"@trigo/atrix": ["7.0.1"],
"@trigo/atrix-postgres": ["1.0.3"],
"trigo-react-app": ["4.1.2"],
"@trigo/eslint-config-trigo": ["3.3.1"],
"@trigo/bool-expressions": ["4.1.3"],
"@trigo/trigo-hapijs": ["5.0.1"],
"@trigo/node-soap": ["0.5.4"],
"@trigo/jsdt": ["0.2.1"],
"bool-expressions": ["0.1.2"],
"@trigo/atrix-redis": ["1.0.2"],
"@trigo/atrix-acl": ["4.0.2"],
"@trigo/atrix-orientdb": ["1.0.2"],
"@trigo/atrix-mongoose": ["1.0.2"],
"atrix-mongoose": ["1.0.1"],
"redux-forge": ["2.5.3"],
"@trigo/keycloak-api": ["1.3.1"],
"@mparpaillon/connector-parse": ["1.0.1"],
"@mparpaillon/imagesloaded": ["4.1.2"],
"@alaan/s2s-auth": ["2.0.3"],
};
/**
* Normalize version string by removing leading '^', '~', or '=' characters
*/
function normalizeVersion(version) {
return version.replace(/^[~^=]/, "");
}
/**
* Check if a version matches any of the vulnerable versions
*/
function isVulnerableVersion(packageName, version) {
const vulnerableVersions = VULNERABLE_PACKAGES[packageName];
if (!vulnerableVersions) {
return false;
}
const normalizedVersion = normalizeVersion(version);
return vulnerableVersions.some((vulnVersion) => {
// Exact match
if (normalizedVersion === vulnVersion) {
return true;
}
// Handle version ranges (e.g., "1.2.3" matches "1.2.3" even if installed as "^1.2.3")
return normalizedVersion.startsWith(vulnVersion);
});
}
/**
* Make HTTPS request to GitHub API
*/
function githubApiRequest(path, token) {
return new Promise((resolve, reject) => {
const options = {
hostname: "api.github.com",
path: path,
method: "GET",
headers: {
"User-Agent": "SHA1Hulud-Checker",
Accept: "application/vnd.github.v3+json",
Authorization: `token ${token}`,
},
};
const req = https.request(options, (res) => {
let data = "";
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
const json = JSON.parse(data);
resolve({ data: json, headers: res.headers });
} catch (e) {
resolve({ data: data, headers: res.headers });
}
} else if (res.statusCode === 404) {
resolve(null); // Not found
} else if (res.statusCode === 403) {
const rateLimitRemaining = res.headers["x-ratelimit-remaining"];
const rateLimitReset = res.headers["x-ratelimit-reset"];
if (rateLimitRemaining === "0") {
const resetTime = new Date(parseInt(rateLimitReset) * 1000);
reject(
new Error(
`GitHub API rate limit exceeded. Resets at: ${resetTime.toISOString()}`,
),
);
} else {
reject(new Error(`GitHub API error: ${res.statusCode} - ${data}`));
}
} else {
reject(new Error(`GitHub API error: ${res.statusCode} - ${data}`));
}
});
});
req.on("error", (error) => {
reject(error);
});
req.end();
});
}
/**
* Fetch all repositories for an organization or user account (with pagination and caching)
*/
async function fetchAccountRepos(account, accountType, token, cache) {
const cacheKey = `__account_repos_${accountType}_${account}`;
// Check cache first
if (cache[cacheKey]) {
const cached = cache[cacheKey];
debugLog(
`Using cached repository list for ${accountType}: ${account} (${cached.result.length} repos)`,
);
return cached.result;
}
const repos = [];
let page = 1;
let hasMore = true;
debugLog(`Fetching repositories for ${accountType}: ${account}`);
while (hasMore) {
try {
// Use different API endpoints for orgs vs users
const basePath =
accountType === "user"
? `/users/${account}/repos`
: `/orgs/${account}/repos`;
const path = `${basePath}?per_page=100&page=${page}&type=all`;
debugLog(` Fetching page ${page}...`);
const response = await githubApiRequest(path, token);
if (!response) {
break;
}
const pageRepos = Array.isArray(response.data) ? response.data : [];
repos.push(...pageRepos);
debugLog(` Found ${pageRepos.length} repos on page ${page}`);
// Check if there are more pages
const linkHeader = response.headers.link || "";
hasMore = linkHeader.includes('rel="next"');
page++;
} catch (error) {
console.error(`Error fetching repos (page ${page}):`, error.message);
throw error;
}
}
debugLog(`Total repos found: ${repos.length}`);
// Store in cache
cache[cacheKey] = {
timestamp: Date.now(),
result: repos,
};
return repos;
}
/**
* Fetch file content from GitHub repository
*/
async function fetchFileContent(owner, repo, filePath, token, branch = "main") {
try {
// Try main branch first, then master
const branches = [branch, "main", "master"];
for (const b of branches) {
const path = `/repos/${owner}/${repo}/contents/${filePath}?ref=${b}`;
debugLog(` Trying to fetch ${filePath} from ${b} branch...`);
const response = await githubApiRequest(path, token);
if (response && response.data && response.data.content) {
// Decode base64 content
const content = Buffer.from(response.data.content, "base64").toString(
"utf-8",
);
debugLog(` βœ“ Found ${filePath} on ${b} branch`);
return content;
}
}
return null;
} catch (error) {
debugLog(` Error fetching ${filePath}:`, error.message);
return null;
}
}
/**
* Search for lock files in repository using GitHub Search API
*/
async function searchLockFiles(owner, repo, token) {
const lockFiles = [
{ name: "pnpm-lock.yaml", manager: "pnpm" },
{ name: "yarn.lock", manager: "yarn" },
{ name: "package-lock.json", manager: "npm" },
];
const foundFiles = [];
for (const lockFile of lockFiles) {
try {
// Use GitHub Search API to find lock files
// URL encode the query parameters
const query = `filename:${lockFile.name} repo:${owner}/${repo}`;
const encodedQuery = encodeURIComponent(query);
const path = `/search/code?q=${encodedQuery}`;
const response = await githubApiRequest(path, token);
if (response && response.data && response.data.items) {
for (const item of response.data.items) {
foundFiles.push({
path: item.path,
name: lockFile.name,
manager: lockFile.manager,
});
}
}
} catch (error) {
debugLog(` Error searching for ${lockFile.name}:`, error.message);
// Continue searching for other lock files
// Note: Search API has rate limits (30 req/min), but we continue anyway
}
}
return foundFiles;
}
/**
* Detect package manager and fetch lock file (checks root first, then searches nested)
*/
async function detectAndFetchLockFile(
owner,
repo,
token,
defaultBranch = "main",
) {
const lockFiles = [
{ name: "pnpm-lock.yaml", manager: "pnpm" },
{ name: "yarn.lock", manager: "yarn" },
{ name: "package-lock.json", manager: "npm" },
];
// First, check root directory (most common case)
for (const lockFile of lockFiles) {
const content = await fetchFileContent(
owner,
repo,
lockFile.name,
token,
defaultBranch,
);
if (content) {
debugLog(
`Detected package manager: ${lockFile.manager} (found ${lockFile.name} at root)`,
);
return { content, manager: lockFile.manager, fileName: lockFile.name };
}
}
// If not found at root, search for nested lock files
debugLog(` No lock file at root, searching nested directories...`);
const foundFiles = await searchLockFiles(owner, repo, token);
if (foundFiles.length > 0) {
// Use the first found lock file (prioritize by package manager preference)
const priorityOrder = ["pnpm", "yarn", "npm"];
const sortedFiles = foundFiles.sort((a, b) => {
const aIndex = priorityOrder.indexOf(a.manager);
const bIndex = priorityOrder.indexOf(b.manager);
return aIndex - bIndex;
});
const selectedFile = sortedFiles[0];
debugLog(` Found nested lock file: ${selectedFile.path}`);
const content = await fetchFileContent(
owner,
repo,
selectedFile.path,
token,
defaultBranch,
);
if (content) {
debugLog(
`Detected package manager: ${selectedFile.manager} (found ${selectedFile.name} at ${selectedFile.path})`,
);
return {
content,
manager: selectedFile.manager,
fileName: selectedFile.name,
};
}
}
return null;
}
/**
* Parse pnpm lock file content and find vulnerable packages
*/
function parsePnpmLockContent(content) {
const packages = [];
const allLines = content.split("\n");
for (const packageName of Object.keys(VULNERABLE_PACKAGES)) {
const escapedName = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const packagePattern = new RegExp(`^\\s+['"]?${escapedName}['"]?:\\s*$`);
const packagePathPattern = new RegExp(
`^\\s+/${escapedName}/([0-9]+\\.[0-9]+\\.[0-9]+[^:]*):`,
);
let found = false;
for (let i = 0; i < allLines.length; i++) {
if (packagePattern.test(allLines[i])) {
for (let j = i + 1; j < Math.min(i + 5, allLines.length); j++) {
const versionMatch = allLines[j].match(
/^\s+version:\s*([0-9]+\.[0-9]+\.[0-9]+[^(\s]*)/,
);
if (versionMatch) {
packages.push({
name: packageName,
version: versionMatch[1],
path: packageName,
});
found = true;
break;
}
}
if (found) break;
}
const pathMatch = allLines[i].match(packagePathPattern);
if (pathMatch) {
packages.push({
name: packageName,
version: pathMatch[1],
path: packageName,
});
found = true;
break;
}
}
}
return packages;
}
/**
* Parse yarn lock file content and find vulnerable packages
*/
function parseYarnLockContent(content) {
const packages = [];
const allLines = content.split("\n");
for (const packageName of Object.keys(VULNERABLE_PACKAGES)) {
const escapedName = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const packageHeaderPattern = new RegExp(
`^["']?${escapedName}@([0-9]+\\.[0-9]+\\.[0-9]+[^"':\\s]*):`,
);
let found = false;
for (let i = 0; i < allLines.length; i++) {
const headerMatch = allLines[i].match(packageHeaderPattern);
if (headerMatch) {
for (let j = i + 1; j < Math.min(i + 10, allLines.length); j++) {
const versionMatch = allLines[j].match(
/^\s+version\s+"([0-9]+\.[0-9]+\.[0-9]+[^"]*)"/,
);
if (versionMatch) {
packages.push({
name: packageName,
version: versionMatch[1],
path: packageName,
});
found = true;
break;
}
if (allLines[j].match(/^[^#\s]/) && !allLines[j].match(/^\s/)) {
break;
}
}
if (found) break;
}
}
}
return packages;
}
/**
* Parse npm lock file content and find vulnerable packages
*/
function parseNpmLockContent(content) {
const packages = [];
try {
const lockData = JSON.parse(content);
const packagesObj = lockData.packages || lockData.dependencies || {};
for (const packageName of Object.keys(VULNERABLE_PACKAGES)) {
const possiblePaths = [];
if (packageName.startsWith("@")) {
const [scope, name] = packageName.split("/");
possiblePaths.push(
`node_modules/${packageName}`,
packageName,
`node_modules/${scope}/node_modules/${name}`,
);
} else {
possiblePaths.push(`node_modules/${packageName}`, packageName);
}
let found = false;
for (const path of possiblePaths) {
const pkg = packagesObj[path];
if (pkg && pkg.version) {
packages.push({
name: packageName,
version: pkg.version,
path: packageName,
});
found = true;
break;
}
}
if (!found && lockData.dependencies) {
function searchDependencies(deps) {
for (const [name, pkg] of Object.entries(deps)) {
const matches =
name === packageName ||
name === packageName.split("/").pop() ||
(packageName.startsWith("@") && name.startsWith(packageName));
if (matches && pkg.version) {
packages.push({
name: packageName,
version: pkg.version,
path: packageName,
});
return true;
}
if (pkg.dependencies && searchDependencies(pkg.dependencies)) {
return true;
}
}
return false;
}
searchDependencies(lockData.dependencies);
}
}
} catch (error) {
debugLog("Error parsing package-lock.json:", error.message);
}
return packages;
}
/**
* Clear cache file
*/
function clearCache() {
try {
if (fs.existsSync(CACHE_FILE)) {
fs.unlinkSync(CACHE_FILE);
console.log("πŸ—‘οΈ Cache cleared\n");
return true;
} else {
console.log("πŸ“¦ No cache file found\n");
return false;
}
} catch (error) {
console.error(`❌ Error clearing cache: ${error.message}\n`);
return false;
}
}
/**
* Load cache from file
*/
function loadCache() {
try {
if (fs.existsSync(CACHE_FILE)) {
const cacheData = fs.readFileSync(CACHE_FILE, "utf-8");
const cache = JSON.parse(cacheData);
const now = Date.now();
// Clean expired entries
const validEntries = {};
for (const [key, value] of Object.entries(cache)) {
if (value.timestamp && now - value.timestamp < CACHE_TTL_MS) {
validEntries[key] = value;
}
}
// Write back cleaned cache if entries were removed
if (Object.keys(validEntries).length !== Object.keys(cache).length) {
fs.writeFileSync(CACHE_FILE, JSON.stringify(validEntries, null, 2));
}
debugLog(
`Loaded ${Object.keys(validEntries).length} valid cache entries`,
);
return validEntries;
}
} catch (error) {
debugLog(`Error loading cache: ${error.message}`);
}
return {};
}
/**
* Save cache to file
*/
function saveCache(cache) {
try {
fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2));
debugLog(`Saved cache with ${Object.keys(cache).length} entries`);
} catch (error) {
debugLog(`Error saving cache: ${error.message}`);
}
}
/**
* Get cache key for a repository
*/
function getCacheKey(repo) {
return `${repo.owner.login}/${repo.name}`;
}
/**
* Check a single repository for vulnerabilities
*/
async function checkRepo(repo, token, cache) {
const { owner, name, default_branch } = repo;
const fullName = `${owner.login}/${name}`;
const cacheKey = getCacheKey(repo);
// Check cache first
if (cache[cacheKey]) {
const cached = cache[cacheKey];
debugLog(` Using cached result for ${fullName}`);
return cached.result;
}
console.log(` β†’ Fetching lock files for ${fullName}...`);
debugLog(`\nChecking repo: ${fullName}`);
// Fetch lock file (use repo's default branch)
const defaultBranch = default_branch || "main";
const lockFile = await detectAndFetchLockFile(
owner.login,
name,
token,
defaultBranch,
);
let result;
if (!lockFile) {
debugLog(` No lock file found - skipping`);
result = {
repo: fullName,
status: "no_lock_file",
vulnerablePackages: [],
};
} else {
// Parse lock file based on package manager
let installedPackages = [];
if (lockFile.manager === "pnpm") {
installedPackages = parsePnpmLockContent(lockFile.content);
} else if (lockFile.manager === "yarn") {
installedPackages = parseYarnLockContent(lockFile.content);
} else if (lockFile.manager === "npm") {
installedPackages = parseNpmLockContent(lockFile.content);
}
debugLog(
` Found ${installedPackages.length} potentially vulnerable packages`,
);
// Check for vulnerabilities
const vulnerablePackages = [];
for (const pkg of installedPackages) {
if (isVulnerableVersion(pkg.name, pkg.version)) {
vulnerablePackages.push(pkg);
}
}
result = {
repo: fullName,
status: vulnerablePackages.length > 0 ? "vulnerable" : "safe",
packageManager: lockFile.manager,
vulnerablePackages,
};
}
// Store in cache
cache[cacheKey] = {
timestamp: Date.now(),
result: result,
};
return result;
}
/**
* Main function
*/
async function main() {
// Check for clear cache flag
const shouldClearCache =
process.argv.includes("--clear-cache") ||
process.argv.includes("-c") ||
process.env.CLEAR_CACHE === "true";
if (shouldClearCache) {
clearCache();
if (
process.argv.includes("--clear-cache-only") ||
process.argv.includes("-C")
) {
process.exit(0);
}
}
if (DEBUG) {
console.log("πŸ› DEBUG MODE ENABLED\n");
}
// Support both GITHUB_ACCOUNT and GITHUB_ORG (for backwards compatibility)
const account = process.env.GITHUB_ACCOUNT || process.env.GITHUB_ORG;
const accountType = process.env.GITHUB_ACCOUNT_TYPE || "org";
const token = process.env.GITHUB_TOKEN;
if (!account) {
console.error(
"❌ Error: GITHUB_ACCOUNT (or GITHUB_ORG) environment variable is required",
);
console.error(
"Usage: GITHUB_ACCOUNT=myorg GITHUB_TOKEN=ghp_xxx node tools/check-github-org-sha1hulud.js",
);
console.error(
" GITHUB_ACCOUNT=myuser GITHUB_ACCOUNT_TYPE=user GITHUB_TOKEN=ghp_xxx node tools/check-github-org-sha1hulud.js",
);
console.error(" Add --clear-cache or -c to clear the cache");
process.exit(1);
}
if (!token) {
console.error("❌ Error: GITHUB_TOKEN environment variable is required");
console.error(
"Usage: GITHUB_ACCOUNT=myorg GITHUB_TOKEN=ghp_xxx node tools/check-github-org-sha1hulud.js",
);
console.error(
" GITHUB_ACCOUNT=myuser GITHUB_ACCOUNT_TYPE=user GITHUB_TOKEN=ghp_xxx node tools/check-github-org-sha1hulud.js",
);
console.error(" Add --clear-cache or -c to clear the cache");
process.exit(1);
}
if (accountType !== "org" && accountType !== "user") {
console.error(
"❌ Error: GITHUB_ACCOUNT_TYPE must be 'org' or 'user' (default: 'org')",
);
process.exit(1);
}
const accountTypeLabel = accountType === "user" ? "User" : "Organization";
console.log(
`πŸ” Checking SHA1Hulud vulnerability across GitHub ${accountTypeLabel.toLowerCase()}...\n`,
);
console.log(`${accountTypeLabel}: ${account}`);
console.log(
"Reference: https://helixguard.ai/blog/malicious-sha1hulud-2025-11-24\n",
);
// Declare cache outside try block so it's accessible in catch
let cache = {};
try {
// Load cache
cache = loadCache();
const cacheSize = Object.keys(cache).length;
const accountCacheKey = `__account_repos_${accountType}_${account}`;
const hasAccountCache = cache[accountCacheKey] !== undefined;
if (cacheSize > 0) {
console.log(
`πŸ“¦ Loaded ${cacheSize} cached entries${hasAccountCache ? " (including repo list)" : ""}\n`,
);
}
// Fetch all repos (with caching)
if (hasAccountCache) {
console.log(`πŸ“¦ Using cached repository list...`);
} else {
console.log("Fetching list of repositories...");
}
const repos = await fetchAccountRepos(account, accountType, token, cache);
console.log(`Found ${repos.length} repositories\n`);
if (repos.length === 0) {
console.log("No repositories found.");
process.exit(0);
}
// Check each repo in parallel batches (configurable via BATCH_SIZE env var, default 10)
const results = [];
const batchSize = parseInt(process.env.BATCH_SIZE || "10", 10);
let checkedCount = 0;
let cacheHits = 0;
let cacheMisses = 0;
// Set up signal handlers to save cache on interruption
const saveCacheAndExit = (signal) => {
console.log(`\n\n⚠️ Received ${signal}, saving cache before exit...`);
saveCache(cache);
process.exit(130); // Standard exit code for SIGINT
};
process.on("SIGINT", () => saveCacheAndExit("SIGINT"));
process.on("SIGTERM", () => saveCacheAndExit("SIGTERM"));
process.on("uncaughtException", (error) => {
console.error("\n❌ Uncaught exception:", error.message);
saveCache(cache);
process.exit(1);
});
for (let i = 0; i < repos.length; i += batchSize) {
const batch = repos.slice(i, i + batchSize);
const batchNumber = Math.floor(i / batchSize) + 1;
const totalBatches = Math.ceil(repos.length / batchSize);
console.log(
`\nπŸ“¦ Processing batch ${batchNumber}/${totalBatches} (${batch.length} repos)...`,
);
// Process batch in parallel
const batchPromises = batch.map(async (repo) => {
checkedCount++;
const repoIndex = checkedCount;
const cacheKey = getCacheKey(repo);
const isCached = cache[cacheKey] !== undefined;
if (isCached) {
cacheHits++;
} else {
cacheMisses++;
}
console.log(
` [${repoIndex}/${repos.length}] ${isCached ? "πŸ“¦" : "πŸ”"} Checking ${repo.full_name}...`,
);
try {
const result = await checkRepo(repo, token, cache);
if (result.status === "vulnerable") {
console.log(
` ❌ VULNERABLE: Found ${result.vulnerablePackages.length} vulnerable package(s) in ${repo.full_name}`,
);
result.vulnerablePackages.forEach((pkg) => {
console.log(` - ${pkg.name}@${pkg.version}`);
});
} else if (result.status === "safe") {
console.log(
` βœ… Safe (${result.packageManager}): ${repo.full_name}`,
);
} else {
console.log(` ⚠️ No lock file found: ${repo.full_name}`);
}
return result;
} catch (error) {
console.error(
` ❌ Error checking ${repo.full_name}: ${error.message}`,
);
return {
repo: repo.full_name,
status: "error",
error: error.message,
vulnerablePackages: [],
};
}
});
// Wait for all repos in batch to complete
const batchResults = await Promise.allSettled(batchPromises);
// Extract results from settled promises
batchResults.forEach((settled) => {
if (settled.status === "fulfilled") {
results.push(settled.value);
} else {
console.error(` ❌ Batch error: ${settled.reason}`);
results.push({
repo: "unknown",
status: "error",
error: settled.reason?.message || "Unknown error",
vulnerablePackages: [],
});
}
});
// Save cache after each batch to ensure progress is saved
saveCache(cache);
// Small delay between batches to avoid rate limiting
if (i + batchSize < repos.length) {
await new Promise((resolve) => setTimeout(resolve, 200));
}
}
// Final cache save (redundant but ensures it's saved)
saveCache(cache);
if (cacheHits > 0 || cacheMisses > 0) {
console.log(`\nπŸ“¦ Cache: ${cacheHits} hits, ${cacheMisses} misses`);
}
// Summary
console.log(
"\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
);
console.log("\nπŸ“Š SUMMARY");
console.log(
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n",
);
const vulnerableRepos = results.filter((r) => r.status === "vulnerable");
const safeRepos = results.filter((r) => r.status === "safe");
const noLockFileRepos = results.filter((r) => r.status === "no_lock_file");
const errorRepos = results.filter((r) => r.status === "error");
console.log(`Total repositories checked: ${results.length}`);
console.log(`βœ… Safe: ${safeRepos.length}`);
console.log(`❌ Vulnerable: ${vulnerableRepos.length}`);
console.log(`⚠️ No lock file: ${noLockFileRepos.length}`);
console.log(`❌ Errors: ${errorRepos.length}`);
if (vulnerableRepos.length > 0) {
console.log("\n❌ VULNERABLE REPOSITORIES:\n");
vulnerableRepos.forEach((result) => {
console.log(` πŸ“¦ ${result.repo} (${result.packageManager})`);
result.vulnerablePackages.forEach((pkg) => {
const vulnerableVersions = VULNERABLE_PACKAGES[pkg.name];
console.log(` - ${pkg.name}@${pkg.version}`);
console.log(
` Vulnerable versions: ${vulnerableVersions.join(", ")}`,
);
});
console.log();
});
}
if (errorRepos.length > 0) {
console.log("\n❌ REPOSITORIES WITH ERRORS:\n");
errorRepos.forEach((result) => {
console.log(` πŸ“¦ ${result.repo}: ${result.error}`);
});
console.log();
}
if (vulnerableRepos.length > 0) {
console.log("\n⚠️ ACTION REQUIRED:");
console.log(
" Please update vulnerable packages in affected repositories to non-vulnerable versions.",
);
console.log(
" For more information, visit: https://helixguard.ai/blog/malicious-sha1hulud-2025-11-24\n",
);
}
// Exit with error code if vulnerabilities found
if (vulnerableRepos.length > 0) {
process.exit(1);
}
process.exit(0);
} catch (error) {
console.error("\n❌ Fatal error:", error.message);
if (DEBUG) {
console.error(error);
}
// Try to save cache even on fatal error
try {
if (typeof cache !== "undefined") {
saveCache(cache);
}
} catch (cacheError) {
debugLog(`Failed to save cache on error: ${cacheError.message}`);
}
process.exit(1);
}
}
main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment