worm-sign
Version:
A prescient scanner to detect and banish Shai Hulud malware from your dependencies.
396 lines (395 loc) • 17.1 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.SOURCES = exports.loadConfig = exports.parseCsv = exports.loadCsv = exports.analyzeScripts = void 0;
exports.loadJson = loadJson;
exports.fetchFromApi = fetchFromApi;
exports.fetchCompromisedPackages = fetchCompromisedPackages;
exports.scanProject = scanProject;
const fs = __importStar(require("fs"));
const crypto = __importStar(require("crypto"));
const path = __importStar(require("path"));
const https = __importStar(require("https"));
const arborist_1 = __importDefault(require("@npmcli/arborist"));
const analysis_1 = require("./analysis");
Object.defineProperty(exports, "analyzeScripts", { enumerable: true, get: function () { return analysis_1.analyzeScripts; } });
const entropy_1 = require("./heuristics/entropy");
const signatures_1 = require("./generated/signatures");
const validators_1 = require("./utils/validators");
const csv_1 = require("./utils/csv");
Object.defineProperty(exports, "loadCsv", { enumerable: true, get: function () { return csv_1.loadCsv; } });
Object.defineProperty(exports, "parseCsv", { enumerable: true, get: function () { return csv_1.parseCsv; } });
const yarn_1 = __importDefault(require("./package-managers/yarn"));
const pnpm_1 = __importDefault(require("./package-managers/pnpm"));
var config_1 = require("./config");
Object.defineProperty(exports, "loadConfig", { enumerable: true, get: function () { return config_1.loadConfig; } });
function loadJson(filePath) {
try {
const raw = fs.readFileSync(filePath, 'utf8');
const json = JSON.parse(raw);
if (Array.isArray(json)) {
// Handle array of packages directly
return json;
}
if (json.packages && Array.isArray(json.packages)) {
return json.packages;
}
console.warn(`Warning: JSON at ${filePath} does not contain a "packages" array or is not an array.`);
return [];
}
catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.warn(`Warning: Failed to parse JSON ${filePath}: ${msg}`);
return [];
}
}
exports.SOURCES = {
datadog: {
url: 'https://raw.githubusercontent.com/DataDog/indicators-of-compromise/main/shai-hulud-2.0/consolidated_iocs.csv',
type: 'csv',
},
koi: {
url: 'https://docs.google.com/spreadsheets/d/16aw6s7mWoGU7vxBciTEZSaR5HaohlBTfVirvI-PypJc/export?format=csv&gid=1289659284',
type: 'csv',
},
// TODO: Update IBM URL as it is currently returning 404
/*
ibm: {
url: 'https://raw.githubusercontent.com/IBM/security-intelligence/master/threat-intel/shai-hulud.csv',
type: 'csv',
},
*/
};
function fetchFromApi(sourceConfig) {
const { url, type, insecure } = sourceConfig;
if (!url || !type) {
return Promise.reject(new Error('Invalid source configuration: missing url or type'));
}
const fetchUrl = async (targetUrl, attempt = 1) => {
// SSRF Protection: Resolve and validate IP first
const resolvedIp = await (0, validators_1.validateUrl)(targetUrl);
const urlObj = new URL(targetUrl);
return new Promise((resolve, reject) => {
if (attempt > 5) {
reject(new Error('Too many redirects'));
return;
}
const options = {
hostname: resolvedIp,
port: urlObj.port || 443,
path: urlObj.pathname + urlObj.search,
servername: urlObj.hostname, // SNI
headers: {
Host: urlObj.hostname, // Host header
Accept: type === 'json' ? 'application/json' : 'text/csv',
'User-Agent': 'worm-sign',
},
rejectUnauthorized: !insecure,
};
const req = https.get(options, (res) => {
if (res.statusCode &&
res.statusCode >= 300 &&
res.statusCode < 400 &&
res.headers.location) {
// Follow redirect
const redirectUrl = res.headers.location;
if (!redirectUrl) {
reject(new Error('Redirect without location header'));
return;
}
// Recursive call will validate the new URL
fetchUrl(redirectUrl, attempt + 1)
.then(resolve)
.catch(reject);
return;
}
if (res.statusCode && res.statusCode !== 200) {
reject(new Error(`API request failed with status ${res.statusCode}`));
res.resume(); // Consume response
return;
}
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
try {
if (type === 'json') {
const json = JSON.parse(data);
if (!Array.isArray(json.packages)) {
reject(new Error('Invalid API response: "packages" field must be an array.'));
return;
}
resolve(json.packages);
}
else if (type === 'csv') {
resolve((0, csv_1.parseCsv)(data));
}
}
catch (e) {
const msg = e instanceof Error ? e.message : String(e);
reject(new Error(`Failed to parse API response: ${msg}`));
}
});
});
req.on('error', reject);
req.setTimeout(5000, () => {
req.destroy();
reject(new Error('API request timed out after 5000ms'));
});
});
};
return fetchUrl(url);
}
async function fetchCompromisedPackages(sources) {
const allPackages = [];
const errors = [];
for (const config of sources) {
const name = config.name || config.url;
try {
const pkgs = await fetchFromApi(config);
allPackages.push(...pkgs);
}
catch (error) {
const msg = error instanceof Error ? error.message : String(error);
errors.push(`Failed to fetch from ${name}: ${msg}`);
}
}
if (allPackages.length === 0 && errors.length > 0) {
// If everything failed, we still return the errors, but maybe we should let the caller decide if it's fatal?
// The caller (CLI) will see 0 packages and N errors.
}
// Deduplicate
const uniqueMap = new Map();
allPackages.forEach((p) => {
const key = `${p.name}@${p.version}`;
if (!uniqueMap.has(key)) {
uniqueMap.set(key, p);
}
});
return { packages: Array.from(uniqueMap.values()), errors };
}
function buildCompromisedMap(entries) {
const map = new Map();
for (const { name, version } of entries) {
if (!name)
continue;
const info = map.get(name) ?? { versions: new Set(), wildcard: false, hashes: new Set() };
const ver = version?.trim();
if (!ver || ver === '*' || ver.toLowerCase() === 'any') {
info.wildcard = true;
}
else {
info.versions.add(ver);
}
const entry = entries.find((e) => e.name === name && e.version === version);
if (entry?.integrity) {
info.hashes.add(entry.integrity);
}
map.set(name, info);
}
return map;
}
function shouldFlag(compromisedInfo, version, integrity) {
if (!compromisedInfo)
return false;
if (compromisedInfo.wildcard)
return true;
if (compromisedInfo.versions.has(version))
return true;
if (integrity && compromisedInfo.hashes.size > 0) {
for (const hash of compromisedInfo.hashes) {
if (integrity.includes(hash))
return true;
}
}
return false;
}
async function scanProject(projectRoot, compromisedListSource, options) {
const debug = (msg) => {
if (options?.debug)
console.log(`[DEBUG] ${msg}`);
};
const resolvedRoot = path.resolve(projectRoot);
debug(`Scanning project at: ${resolvedRoot}`);
if (!fs.existsSync(resolvedRoot)) {
throw new Error(`Project root does not exist: ${resolvedRoot}`);
}
const packageJsonPath = path.join(resolvedRoot, 'package.json');
const allWarnings = [];
const allFindings = [];
let compromisedEntries;
if (Array.isArray(compromisedListSource)) {
compromisedEntries = compromisedListSource;
}
else if (typeof compromisedListSource === 'string') {
if (!fs.existsSync(compromisedListSource)) {
throw new Error(`Compromised list not found at ${compromisedListSource}`);
}
compromisedEntries = (0, csv_1.loadCsv)(compromisedListSource);
}
else {
throw new Error('Invalid compromised list source. Must be a file path or an array.');
}
if (!fs.existsSync(packageJsonPath)) {
throw new Error(`package.json not found at ${packageJsonPath}`);
}
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
debug('Loaded package.json');
// 1. Analyze Root Scripts
const scriptFindings = (0, analysis_1.analyzeScripts)(packageJson, options?.config);
allFindings.push(...scriptFindings);
// Backwards compatibility: push messages to warnings
scriptFindings.forEach((f) => allWarnings.push(f.message));
// 2. Check for known malware files in root
const KNOWN_MALWARE_HASHES = new Set([
'a3894003ad1d293ba96d77881ccd2071446dc3f65f434669b49b3da92421901a', // setup_bun.js
'62ee164b9b306250c1172583f138c9614139264f889fa99614903c12755468d0', // bun_environment.js
'cbb9bc5a8496243e02f3cc080efbe3e4a1430ba0671f2e43a202bf45b05479cd', // bun_environment.js
'f099c5d9ec417d4445a0328ac0ada9cde79fc37410914103ae9c609cbc0ee068', // bun_environment.js
]);
for (const file of signatures_1.MALWARE_FILENAMES) {
const filePath = path.join(resolvedRoot, file);
if (fs.existsSync(filePath)) {
try {
const stats = fs.statSync(filePath);
const isLarge = stats.size > 5 * 1024 * 1024;
const hash = crypto.createHash('sha256');
// Only calculate entropy if file is large (optimization)
const entropyCalc = isLarge ? new entropy_1.EntropyCalculator() : null;
const stream = fs.createReadStream(filePath);
await new Promise((resolve, reject) => {
stream.on('data', (chunk) => {
hash.update(chunk);
if (entropyCalc) {
entropyCalc.update(chunk);
}
});
stream.on('error', reject);
stream.on('end', resolve);
});
const finalHash = hash.digest('hex');
if (KNOWN_MALWARE_HASHES.has(finalHash)) {
allWarnings.push(`CONFIRMED MALWARE file detected: '${file}' (Hash match: ${finalHash})`);
}
else if (isLarge && entropyCalc) {
const entropy = entropyCalc.digest();
// Threshold 7.0 for packed malware in JS context
if (entropy > 7.0) {
allWarnings.push(`HIGH RISK file detected: '${file}' (High Entropy: ${entropy.toFixed(2)}, Size: ${stats.size} bytes)`);
}
allWarnings.push(`Suspicious file detected: '${file}' (associated with Shai Hulud)`);
}
else {
allWarnings.push(`Suspicious file detected: '${file}' (associated with Shai Hulud)`);
}
}
catch (e) {
const msg = e instanceof Error ? e.message : String(e);
allWarnings.push(`Suspicious file detected: '${file}' (associated with Shai Hulud) - could not read: ${msg}`);
}
}
}
// 3. Scan Dependencies
const compromisedMap = buildCompromisedMap(compromisedEntries);
const matches = [];
let treeLoaded = false;
// Try npm (Arborist) first
try {
const arb = new arborist_1.default({ path: resolvedRoot });
const tree = await arb.loadVirtual();
debug(`Loaded dependency tree with ${tree.inventory.size} nodes.`);
treeLoaded = true;
for (const node of tree.inventory.values()) {
const { name, version, integrity } = node;
const info = compromisedMap.get(name);
if (shouldFlag(info, version, integrity)) {
const section = node.dev ? 'devDependencies' : 'dependencies';
matches.push({ name, version, section });
}
}
}
catch (e) {
const msg = e instanceof Error ? e.message : String(e);
debug(`Arborist failed: ${msg}`);
// If it's not a missing lockfile error, rethrow
if (!msg.includes('ENOENT') && !msg.includes('lockfile') && !msg.includes('shrinkwrap')) {
throw new Error(`Failed to load dependency tree: ${msg}`);
}
}
// Fallback to Yarn or pnpm if npm failed
if (!treeLoaded) {
debug('Checking for Yarn or pnpm lockfiles...');
const handlers = [yarn_1.default, pnpm_1.default];
let handlerFound = false;
for (const handler of handlers) {
const lockFile = handler.findLockFile(resolvedRoot);
if (lockFile) {
debug(`Found ${handler.label} lockfile: ${lockFile}`);
handlerFound = true;
const result = handler.loadLockPackages(lockFile);
if (!result.success) {
result.warnings.forEach((w) => allWarnings.push(w));
continue;
}
// Iterate over loaded packages
for (const [name, versions] of result.packages.entries()) {
const info = compromisedMap.get(name);
if (!info)
continue;
for (const version of versions) {
// Get integrity if available
let integrity;
if (result.packageIntegrity) {
const pkgIntegrity = result.packageIntegrity.get(name);
if (pkgIntegrity) {
integrity = pkgIntegrity.get(version);
}
}
if (shouldFlag(info, version, integrity)) {
matches.push({ name, version, section: 'locked' });
}
}
}
break; // Stop after first successful handler
}
}
if (!handlerFound) {
throw new Error('No lockfile found (package-lock.json, yarn.lock, pnpm-lock.yaml). Please install dependencies.');
}
}
return { matches, warnings: allWarnings, findings: allFindings };
}