Skip to content

Instantly share code, notes, and snippets.

@Lp-Francois
Last active November 28, 2025 06:21
Show Gist options
  • Select an option

  • Save Lp-Francois/cf203ef12ffd597dceb4716900e0dbe1 to your computer and use it in GitHub Desktop.

Select an option

Save Lp-Francois/cf203ef12ffd597dceb4716900e0dbe1 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 for SHA1Hulud vulnerabilities by analyzing lock files via GitHub API (no cloning required).

Usage

export GITHUB_ORG=myorg
export GITHUB_TOKEN=ghp_xxx
curl -s https://gist.githubusercontent.com/Lp-Francois/cf203ef12ffd597dceb4716900e0dbe1/raw/392abd4bf134245085fb877ed837d47b5b60b487/check-github-org-sha1hulud.js | node

Options

  • 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 for SHA1Hulud vulnerabilities
* by fetching lock files directly via GitHub API (no cloning required)
*
* Usage: GITHUB_ORG=myorg 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_ORG - GitHub organization name (required)
* 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 in an organization (with pagination and caching)
*/
async function fetchOrgRepos(org, token, cache) {
const cacheKey = `__org_repos_${org}`;
// Check cache first
if (cache[cacheKey]) {
const cached = cache[cacheKey];
debugLog(
`Using cached repository list for org: ${org} (${cached.result.length} repos)`
);
return cached.result;
}
const repos = [];
let page = 1;
let hasMore = true;
debugLog(`Fetching repositories for org: ${org}`);
while (hasMore) {
try {
const path = `/orgs/${org}/repos?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');
}
const org = process.env.GITHUB_ORG;
const token = process.env.GITHUB_TOKEN;
if (!org) {
console.error('❌ Error: GITHUB_ORG environment variable is required');
console.error(
'Usage: GITHUB_ORG=myorg 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_ORG=myorg 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);
}
console.log(
'πŸ” Checking SHA1Hulud vulnerability across GitHub organization...\n'
);
console.log(`Organization: ${org}`);
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 orgCacheKey = `__org_repos_${org}`;
const hasOrgCache = cache[orgCacheKey] !== undefined;
if (cacheSize > 0) {
console.log(
`πŸ“¦ Loaded ${cacheSize} cached entries${hasOrgCache ? ' (including repo list)' : ''}\n`
);
}
// Fetch all repos (with caching)
if (hasOrgCache) {
console.log(`πŸ“¦ Using cached repository list...`);
} else {
console.log('Fetching list of repositories...');
}
const repos = await fetchOrgRepos(org, 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