+11
-4
@@ -273,6 +273,13 @@ #!/usr/bin/env node | ||
| if (matches.length > 0) { | ||
| console.log(chalk.red(`\n🚫 FOUND ${matches.length} COMPROMISED PACKAGES:`)); | ||
| matches.forEach((m) => { | ||
| console.log(chalk.red(` - ${m.name}@${m.version}`) + chalk.dim(` (found in ${m.section})`)); | ||
| }); | ||
| const title = chalk.bold.red(`🚫 FOUND ${matches.length} COMPROMISED PACKAGES`); | ||
| const list = matches | ||
| .map((m) => chalk.red(` - ${m.name}@${m.version}`) + chalk.dim(` (found in ${m.section})`)) | ||
| .join('\n'); | ||
| console.log(boxen(`${title}\n\n${list}`, { | ||
| padding: 1, | ||
| borderStyle: 'double', | ||
| borderColor: 'red', | ||
| title: 'CRITICAL SECURITY ALERT', | ||
| titleAlignment: 'center', | ||
| })); | ||
| } | ||
@@ -279,0 +286,0 @@ } |
| { | ||
| "name": "worm-sign", | ||
| "version": "3.0.0", | ||
| "version": "3.0.1", | ||
| "description": "A prescient scanner to detect and banish Shai Hulud malware from your dependencies.", | ||
@@ -5,0 +5,0 @@ "main": "dist/src/index.js", |
| "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.EntropyCalculator = void 0; | ||
| exports.calculateEntropy = calculateEntropy; | ||
| exports.calculateEntropyStream = calculateEntropyStream; | ||
| exports.isHighEntropy = isHighEntropy; | ||
| /** | ||
@@ -9,5 +14,25 @@ * Shannon Entropy Analysis | ||
| */ | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.calculateEntropy = calculateEntropy; | ||
| exports.isHighEntropy = isHighEntropy; | ||
| class EntropyCalculator { | ||
| frequencies = {}; | ||
| totalBytes = 0; | ||
| update(chunk) { | ||
| const len = chunk.length; | ||
| this.totalBytes += len; | ||
| for (let i = 0; i < len; i++) { | ||
| const byte = typeof chunk === 'string' ? chunk.charCodeAt(i) : chunk[i]; | ||
| this.frequencies[byte] = (this.frequencies[byte] || 0) + 1; | ||
| } | ||
| } | ||
| digest() { | ||
| if (this.totalBytes === 0) | ||
| return 0; | ||
| let entropy = 0; | ||
| for (const count of Object.values(this.frequencies)) { | ||
| const p = count / this.totalBytes; | ||
| entropy -= p * Math.log2(p); | ||
| } | ||
| return entropy; | ||
| } | ||
| } | ||
| exports.EntropyCalculator = EntropyCalculator; | ||
| /** | ||
@@ -21,19 +46,18 @@ * Calculates the Shannon entropy of a string. | ||
| function calculateEntropy(input) { | ||
| if (!input || input.length === 0) { | ||
| return 0; | ||
| } | ||
| const frequencies = {}; | ||
| const len = input.length; | ||
| for (let i = 0; i < len; i++) { | ||
| const byte = typeof input === 'string' ? input.charCodeAt(i) : input[i]; | ||
| frequencies[byte] = (frequencies[byte] || 0) + 1; | ||
| } | ||
| let entropy = 0; | ||
| for (const count of Object.values(frequencies)) { | ||
| const p = count / len; | ||
| entropy -= p * Math.log2(p); | ||
| } | ||
| return entropy; | ||
| const calculator = new EntropyCalculator(); | ||
| calculator.update(input); | ||
| return calculator.digest(); | ||
| } | ||
| /** | ||
| * Calculates entropy from a readable stream. | ||
| */ | ||
| function calculateEntropyStream(stream) { | ||
| return new Promise((resolve, reject) => { | ||
| const calculator = new EntropyCalculator(); | ||
| stream.on('data', (chunk) => calculator.update(chunk)); | ||
| stream.on('error', reject); | ||
| stream.on('end', () => resolve(calculator.digest())); | ||
| }); | ||
| } | ||
| /** | ||
| * Checks if a string has suspiciously high entropy. | ||
@@ -40,0 +64,0 @@ * |
+91
-30
@@ -58,5 +58,7 @@ "use strict"; | ||
| 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")); | ||
| function loadJson(filePath) { | ||
| const raw = fs.readFileSync(filePath, 'utf8'); | ||
| try { | ||
| const raw = fs.readFileSync(filePath, 'utf8'); | ||
| const json = JSON.parse(raw); | ||
@@ -72,3 +74,2 @@ if (Array.isArray(json)) { | ||
| return []; | ||
| return []; | ||
| } | ||
@@ -104,4 +105,5 @@ catch (e) { | ||
| const fetchUrl = async (targetUrl, attempt = 1) => { | ||
| // SSRF Protection | ||
| await (0, validators_1.validateUrl)(targetUrl); | ||
| // 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) => { | ||
@@ -113,3 +115,8 @@ if (attempt > 5) { | ||
| 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', | ||
@@ -120,3 +127,3 @@ 'User-Agent': 'worm-sign', | ||
| }; | ||
| const req = https.get(targetUrl, options, (res) => { | ||
| const req = https.get(options, (res) => { | ||
| if (res.statusCode && | ||
@@ -291,36 +298,50 @@ res.statusCode >= 300 && | ||
| try { | ||
| const fileBuffer = fs.readFileSync(filePath); | ||
| const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex'); | ||
| if (KNOWN_MALWARE_HASHES.has(hash)) { | ||
| allWarnings.push(`CONFIRMED MALWARE file detected: '${file}' (Hash match: ${hash})`); | ||
| 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 { | ||
| // Check for high entropy (obfuscation) if file is large (> 5MB) | ||
| const stats = fs.statSync(filePath); | ||
| if (stats.size > 5 * 1024 * 1024) { | ||
| const entropy = (0, entropy_1.calculateEntropy)(fileBuffer); | ||
| // Threshold 7.5 for binary/compressed data is conservative; | ||
| // but for text files (js), > 5.2 is suspicious. | ||
| // The test expects "High Entropy" warning. | ||
| // Let's use 7.0 as a safe bet for "packed malware" in JS context if it's huge. | ||
| if (entropy > 7.0) { | ||
| allWarnings.push(`HIGH RISK file detected: '${file}' (High Entropy: ${entropy.toFixed(2)}, Size: ${stats.size} bytes)`); | ||
| } | ||
| 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 { | ||
| allWarnings.push(`Suspicious file detected: '${file}' (associated with Shai Hulud) - could not read hash`); | ||
| 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 using Arborist | ||
| // 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 }); | ||
| // loadVirtual() reads the lockfile and builds the tree without checking node_modules | ||
| const tree = await arb.loadVirtual(); | ||
| debug(`Loaded dependency tree with ${tree.inventory.size} nodes.`); | ||
| treeLoaded = true; | ||
| for (const node of tree.inventory.values()) { | ||
@@ -330,3 +351,2 @@ const { name, version, integrity } = node; | ||
| if (shouldFlag(info, version, integrity)) { | ||
| // Determine section (dev/prod) - Arborist nodes have 'dev' property | ||
| const section = node.dev ? 'devDependencies' : 'dependencies'; | ||
@@ -339,9 +359,50 @@ matches.push({ name, version, section }); | ||
| const msg = e instanceof Error ? e.message : String(e); | ||
| // Fallback or error reporting | ||
| if (msg.includes('ENOENT') && msg.includes('lock')) { | ||
| throw new Error('No lockfile found. Please run npm install/yarn install to generate one.'); | ||
| 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}`); | ||
| } | ||
| 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 }; | ||
| } |
@@ -50,12 +50,23 @@ "use strict"; | ||
| trim: true, | ||
| relax_column_count: true | ||
| relax_column_count: true, | ||
| }); | ||
| return parsed.map((record) => { | ||
| return parsed | ||
| .map((record) => { | ||
| // Try to find name and version fields | ||
| const name = record['package name'] || record['name'] || record['Package Name'] || record['package_name'] || Object.values(record)[0]; | ||
| const version = record['package version'] || record['version'] || record['Package Version'] || record['package_version'] || Object.values(record)[1] || ''; | ||
| const name = record['package name'] || | ||
| record['name'] || | ||
| record['Package Name'] || | ||
| record['package_name'] || | ||
| Object.values(record)[0]; | ||
| const version = record['package version'] || | ||
| record['version'] || | ||
| record['Package Version'] || | ||
| record['package_version'] || | ||
| Object.values(record)[1] || | ||
| ''; | ||
| const reason = record['MSC ID'] || record['reason'] || ''; | ||
| const integrity = record['integrity'] || record['hash'] || record['shasum'] || undefined; | ||
| return { name, version, reason, integrity }; | ||
| }).filter((p) => !!p.name); | ||
| }) | ||
| .filter((p) => !!p.name); | ||
| } | ||
@@ -62,0 +73,0 @@ catch (e) { |
@@ -99,3 +99,3 @@ "use strict"; | ||
| } | ||
| return; | ||
| return hostname; | ||
| } | ||
@@ -113,5 +113,5 @@ // Resolve hostname | ||
| } | ||
| resolve(); | ||
| resolve(address); | ||
| }); | ||
| }); | ||
| } |
@@ -15,3 +15,3 @@ "use strict"; | ||
| // This matches the key in scripts/encrypt-signatures.ts | ||
| const CIPHER_KEY = 0x5F; | ||
| const CIPHER_KEY = 0x5f; | ||
| /** | ||
@@ -18,0 +18,0 @@ * Decrypts a buffer of XOR-encoded bytes back into a string. |
+1
-1
| { | ||
| "name": "worm-sign", | ||
| "version": "3.0.0", | ||
| "version": "3.0.1", | ||
| "description": "A prescient scanner to detect and banish Shai Hulud malware from your dependencies.", | ||
@@ -5,0 +5,0 @@ "main": "dist/src/index.js", |
+3
-0
| # Worm Sign 🪱🚫 | ||
| [](https://github.com/BranLang/worm-sign/actions/workflows/ci.yml) | ||
| [](https://badge.fury.io/js/worm-sign) | ||
| [](https://opensource.org/licenses/MIT) | ||
| [](https://docs.npmjs.com/generating-provenance-statements) | ||
@@ -4,0 +7,0 @@ |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
152277
2.91%1718
6.38%245
1.24%