diff --git a/scripts/ci/scan-supply-chain-iocs.js b/scripts/ci/scan-supply-chain-iocs.js index 447d1284..21e505e8 100755 --- a/scripts/ci/scan-supply-chain-iocs.js +++ b/scripts/ci/scan-supply-chain-iocs.js @@ -493,6 +493,65 @@ function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +function versionSpecifierMatches(value, version) { + if (value === undefined || value === null) return false; + const specifier = String(value); + const versionPattern = new RegExp(`(^|[^0-9A-Za-z.])${escapeRegExp(version)}([^0-9A-Za-z.]|$)`, 'i'); + return specifier === version || versionPattern.test(specifier); +} + +function packageKeyMatches(key, packageName) { + return key === packageName + || key === `node_modules/${packageName}` + || key.endsWith(`/node_modules/${packageName}`); +} + +function jsonReferencesPackageVersion(value, packageName, version) { + if (!value || typeof value !== 'object') return false; + + if (value.name === packageName && versionSpecifierMatches(value.version, version)) { + return true; + } + + for (const [key, child] of Object.entries(value)) { + if (packageKeyMatches(key, packageName)) { + if (typeof child === 'string' && versionSpecifierMatches(child, version)) { + return true; + } + if (child && typeof child === 'object' && versionSpecifierMatches(child.version, version)) { + return true; + } + } + + if (child && typeof child === 'object' && jsonReferencesPackageVersion(child, packageName, version)) { + return true; + } + } + + return false; +} + +function textReferencesPackageVersion(text, packageName, version) { + const escapedPackage = escapeRegExp(packageName); + const escapedVersion = escapeRegExp(version); + const packageToken = `${escapedPackage}(?![A-Za-z0-9._/-])`; + const sameLinePattern = new RegExp(`${packageToken}[^\\n]{0,200}${escapedVersion}(?![0-9A-Za-z.])`, 'i'); + const requirementsPattern = new RegExp(`^\\s*${packageToken}\\s*(?:==|===|~=|>=|<=|>|<)\\s*${escapedVersion}(?![0-9A-Za-z.])`, 'im'); + const poetryNamePattern = new RegExp(`name\\s*=\\s*["']${escapedPackage}["'][\\s\\S]{0,300}?version\\s*=\\s*["']${escapedVersion}["']`, 'i'); + + return sameLinePattern.test(text) + || requirementsPattern.test(text) + || poetryNamePattern.test(text); +} + +function dependencyFileReferencesPackageVersion(text, packageName, version) { + try { + return jsonReferencesPackageVersion(JSON.parse(text), packageName, version); + } catch { + return textReferencesPackageVersion(text, packageName, version); + } +} + function addFinding(findings, severity, filePath, line, indicator, message) { findings.push({ severity, filePath, line, indicator, message }); } @@ -543,17 +602,14 @@ function scanFile(filePath, rootDir, findings) { if (!DEPENDENCY_FILENAMES.has(base)) return; for (const [packageName, versions] of Object.entries(MALICIOUS_PACKAGE_VERSIONS)) { - const packageIndex = lowerText.indexOf(normalizeForMatch(packageName)); - if (packageIndex === -1) continue; - for (const version of versions) { - const versionPattern = new RegExp(`(^|[^0-9a-z.])${escapeRegExp(version)}([^0-9a-z.]|$)`, 'i'); - if (versionPattern.test(text) || lowerText.includes(`@${version}`)) { + if (dependencyFileReferencesPackageVersion(text, packageName, version)) { + const packageIndex = lowerText.indexOf(normalizeForMatch(packageName)); addFinding( findings, 'critical', relativePath, - lineForIndex(text, packageIndex), + lineForIndex(text, packageIndex === -1 ? 0 : packageIndex), `${packageName}@${version}`, 'Dependency manifest or lockfile references a known compromised package version', ); diff --git a/tests/ci/scan-supply-chain-iocs.test.js b/tests/ci/scan-supply-chain-iocs.test.js index ba49b07f..fbff8827 100755 --- a/tests/ci/scan-supply-chain-iocs.test.js +++ b/tests/ci/scan-supply-chain-iocs.test.js @@ -154,6 +154,30 @@ function run() { }); })) passed++; else failed++; + if (test('does not combine package-name substrings with unrelated versions', () => { + withFixture({ + 'package-lock.json': JSON.stringify({ + packages: { + 'node_modules/react-remove-scroll': { + version: '2.6.3', + }, + 'node_modules/@tailwindcss/node': { + version: '4.2.1', + dependencies: { + lightningcss: '1.31.1', + }, + }, + 'node_modules/lightningcss': { + version: '1.31.1', + }, + }, + }, null, 2), + }, rootDir => { + const result = scanSupplyChainIocs({ rootDir }); + assert.deepStrictEqual(result.findings, []); + }); + })) passed++; else failed++; + if (test('does not flag benign substrings in clean package scripts', () => { withFixture({ 'node_modules/uuid/package.json': JSON.stringify({