From c06e827d93202eb71e52adf96c32fdce223d9aee Mon Sep 17 00:00:00 2001 From: VitoAlbano Date: Fri, 29 May 2026 12:31:03 +0100 Subject: [PATCH] [AAE-46514] - Improved the checks to cover more cases --- .vscode/extensions.json | 5 + README.md | 10 +- package.json | 1 + scripts/check-security.mjs | 127 ++++++++++- scripts/postinstall-security.mjs | 7 +- scripts/preinstall-check.mjs | 381 +++++++++++++++++++++++++++++++ 6 files changed, 522 insertions(+), 9 deletions(-) create mode 100644 .vscode/extensions.json create mode 100644 scripts/preinstall-check.mjs diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000000..90edca035c --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "meterian.meterian-heidi" + ] +} diff --git a/README.md b/README.md index d0af2f6fd5..808da6e4d0 100644 --- a/README.md +++ b/README.md @@ -29,11 +29,13 @@ npm install # or npm ci This project has built-in supply chain attack protection. When you run `npm install`: -1. All packages install with scripts disabled (via `.npmrc`) -2. Security check runs against OSV + GitHub Advisory databases (109+ known threats) -3. Only trusted packages (esbuild, nx, husky, etc.) get their scripts executed +1. **Preinstall check** scans both `package.json` AND `package-lock.json` against OSV + GitHub Advisory databases +2. If malicious packages detected → installation blocked BEFORE any code runs +3. Packages install normally +4. **Post-install check** verifies installed packages (defense in depth) +5. Only trusted packages (esbuild, nx, husky, etc.) get their native bindings rebuilt -If a malicious package is detected, installation is blocked and `node_modules` is deleted. +Checking `package.json` catches new dependencies added during upgrades (e.g., `nx migrate`) before the lockfile is updated. If a malicious package is detected at any stage, installation is blocked. ## Components diff --git a/package.json b/package.json index d01a2a7b55..44d5568804 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "version": "8.5.0", "author": "Hyland Software, Inc. and its affiliates", "scripts": { + "preinstall": "node scripts/preinstall-check.mjs", "prepare": "node scripts/postinstall-security.mjs", "bundle:js-api": "nx run js-api:bundle", "bundle:cli": "nx run cli:bundle", diff --git a/scripts/check-security.mjs b/scripts/check-security.mjs index 2c40e721d3..b8f550383d 100644 --- a/scripts/check-security.mjs +++ b/scripts/check-security.mjs @@ -30,10 +30,10 @@ * Cache: Results are cached locally for 24 hours to avoid slowing down installs. */ -import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from 'fs'; +import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; -import { execSync } from 'child_process'; +import { execSync, spawnSync } from 'child_process'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT_DIR = join(__dirname, '..'); @@ -46,6 +46,9 @@ const OSV_API = 'https://api.osv.dev/v1/query'; const OSV_BATCH_API = 'https://api.osv.dev/v1/querybatch'; const GITHUB_ADVISORY_API = 'https://api.github.com/advisories'; +// Meterian CLI (bundled with VSCode extension, also available via npx) +const METERIAN_CLI = '@meterian/cli'; + // Filter for supply chain attacks (malware, compromised packages) // These are the most dangerous - not just vulnerabilities but intentionally malicious const MALWARE_KEYWORDS = [ @@ -174,6 +177,104 @@ async function fetchFromOSV(packages) { return results; } +function findMeterianCli() { + const homeDir = process.env.HOME || process.env.USERPROFILE; + + // Check VSCode extensions first (most likely for local dev) + const extensionsDir = join(homeDir, '.vscode', 'extensions'); + if (existsSync(extensionsDir)) { + try { + const extensions = readdirSync(extensionsDir) + .filter(d => /^meterian\.meterian-heidi-\d+\.\d+\.\d+$/.test(d)) + .sort() + .reverse(); // Latest version first + + for (const ext of extensions) { + const cliPath = join(extensionsDir, ext, 'packages', 'meterian-cli'); + if (existsSync(cliPath)) { + return cliPath; + } + } + } catch { + // Ignore errors reading extensions dir + } + } + + // Other possible locations + const locations = [ + // Local node_modules + join(ROOT_DIR, 'node_modules', '@meterian', 'cli'), + // Global npm (macOS/Linux) + join(homeDir, '.npm-global', 'lib', 'node_modules', '@meterian', 'cli'), + // Global npm (alternative) + '/usr/local/lib/node_modules/@meterian/cli' + ]; + + for (const loc of locations) { + if (existsSync(loc)) { + return loc; + } + } + + return null; +} + +function isMeterianAvailable() { + return findMeterianCli() !== null; +} + +async function checkWithMeterian(dependencies) { + // Skip if Meterian CLI is not available + const cliPath = findMeterianCli(); + if (!cliPath) { + console.log(' ā­ļø Meterian: Not installed, skipping'); + console.log(' Install VSCode extension "Meterian Security" or run: npm i -g @meterian/cli'); + return { vulnerable: [], source: 'Meterian' }; + } + + console.log(' šŸ“” Checking with Meterian CLI...'); + + try { + // Format dependencies for Meterian CLI + const input = dependencies.map(dep => ({ + language: 'nodejs', + name: dep.name, + version: dep.version + })); + + // Run Meterian CLI check using the found path + const cliScript = join(cliPath, 'src', 'cli.js'); + const result = spawnSync('node', [cliScript, 'check'], { + input: JSON.stringify(input), + encoding: 'utf-8', + timeout: 60000, // 60 second timeout + maxBuffer: 10 * 1024 * 1024 + }); + + if (result.error) { + console.log(` ⚠ Meterian: ${result.error.message}`); + return { vulnerable: [], source: 'Meterian' }; + } + + if (result.status !== 0 && !result.stdout) { + console.log(` ⚠ Meterian: CLI returned status ${result.status}`); + return { vulnerable: [], source: 'Meterian' }; + } + + const output = JSON.parse(result.stdout); + console.log(` āœ“ Meterian: Found ${output.vulnerable?.length || 0} vulnerable packages`); + + return { + vulnerable: output.vulnerable || [], + summary: output.summary, + source: 'Meterian' + }; + } catch (e) { + console.log(` ⚠ Meterian: ${e.message}`); + return { vulnerable: [], source: 'Meterian' }; + } +} + async function fetchFromGitHubAdvisory(packages) { console.log(' šŸ“” Fetching from GitHub Advisory...'); const results = {}; @@ -395,6 +496,28 @@ async function main() { violations.push(...checkLockfileDependencies(lockfile.packages, blockedPackages)); } + // Check with Meterian CLI for additional vulnerability coverage + if (lockfile?.packages) { + console.log(''); + const deps = Object.entries(lockfile.packages) + .filter(([path, info]) => path && info.version) + .map(([path, info]) => ({ + name: path.replace(/^node_modules\//, '').replace(/^.*node_modules\//, ''), + version: info.version + })) + .slice(0, 500); // Limit to avoid timeout + + const meterianResult = await checkWithMeterian(deps); + for (const vuln of meterianResult.vulnerable || []) { + violations.push({ + package: vuln.name, + version: vuln.version, + reason: `${vuln.severity}: ${vuln.id}${vuln.safeVersions?.length ? ` (safe: ${vuln.safeVersions[0]})` : ''}`, + source: 'Meterian' + }); + } + } + // Deduplicate const uniqueViolations = violations.filter((v, i, arr) => arr.findIndex(x => x.package === v.package && x.version === v.version) === i diff --git a/scripts/postinstall-security.mjs b/scripts/postinstall-security.mjs index 9108c09bfe..18c63e9b10 100644 --- a/scripts/postinstall-security.mjs +++ b/scripts/postinstall-security.mjs @@ -21,11 +21,12 @@ * Post-install Security Script * * This runs after `npm install` / `npm ci` (via prepare hook). - * Since .npmrc has ignore-scripts=true, no package scripts run during install. + * Preinstall already checked the lockfile - this is a defense-in-depth + * check against installed packages + rebuilds trusted native bindings. * * This script: - * 1. Runs security check against OSV + GitHub Advisory - * 2. If safe, rebuilds only trusted packages that need native bindings + * 1. Runs security check against OSV + GitHub Advisory (defense in depth) + * 2. Rebuilds trusted packages that need native bindings * 3. Sets up husky */ diff --git a/scripts/preinstall-check.mjs b/scripts/preinstall-check.mjs new file mode 100644 index 0000000000..5b025e15e2 --- /dev/null +++ b/scripts/preinstall-check.mjs @@ -0,0 +1,381 @@ +#!/usr/bin/env node + +/*! + * @license + * Copyright Ā© 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Pre-install Security Check + * + * Runs BEFORE packages install. Checks both package.json AND package-lock.json + * against OSV + GitHub Advisory databases to block malicious packages + * BEFORE their postinstall scripts can execute. + * + * Checking package.json catches new dependencies added during upgrades + * (e.g., nx migrate) before the lockfile is updated. + */ + +import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT_DIR = join(__dirname, '..'); +const CACHE_DIR = join(ROOT_DIR, 'node_modules', '.cache', 'security-check'); +const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours + +// Known supply chain attack packages (fallback if APIs fail) +const KNOWN_MALICIOUS = new Set([ + 'event-stream@3.3.6', + 'flatmap-stream@0.1.1', + 'ua-parser-js@0.7.29', + 'coa@2.0.3', 'coa@2.0.4', 'coa@2.1.1', 'coa@2.1.3', 'coa@3.0.1', 'coa@3.1.3', + 'rc@1.2.9', 'rc@1.3.9', 'rc@2.3.9', + 'colors@1.4.1', 'colors@1.4.2', + 'faker@5.5.3', 'faker@6.6.6', + 'node-ipc@10.1.1', 'node-ipc@10.1.2', 'node-ipc@10.1.3', + 'peacenotwar@9.1.3', 'peacenotwar@9.1.4', 'peacenotwar@9.1.5', 'peacenotwar@9.1.6', + 'es5-ext@0.10.53', 'es5-ext@0.10.54', 'es5-ext@0.10.55', 'es5-ext@0.10.56', + '@primevue/themes@4.3.0', '@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3' +]); + +function readCache() { + const cachePath = join(CACHE_DIR, 'threats.json'); + if (!existsSync(cachePath)) return null; + try { + const data = JSON.parse(readFileSync(cachePath, 'utf8')); + if (Date.now() - data.timestamp < CACHE_TTL) { + return new Set(data.threats); + } + } catch { /* ignore */ } + return null; +} + +function writeCache(threats) { + try { + if (!existsSync(CACHE_DIR)) { + mkdirSync(CACHE_DIR, { recursive: true }); + } + writeFileSync(join(CACHE_DIR, 'threats.json'), JSON.stringify({ + timestamp: Date.now(), + threats: [...threats] + })); + } catch { /* ignore */ } +} + +async function fetchOSV() { + try { + const response = await fetch('https://osv-vulnerabilities.storage.googleapis.com/npm/all.zip', { + signal: AbortSignal.timeout(10000) + }); + if (!response.ok) return new Set(); + + const buffer = await response.arrayBuffer(); + const text = new TextDecoder().decode(buffer); + const malicious = new Set(); + + // Parse JSONL format looking for MALWARE type + for (const line of text.split('\n')) { + if (!line.trim()) continue; + try { + const vuln = JSON.parse(line); + if (vuln.database_specific?.type === 'MALWARE' && vuln.affected) { + for (const affected of vuln.affected) { + if (affected.package?.ecosystem === 'npm' && affected.package?.name) { + const versions = affected.versions || []; + for (const v of versions) { + malicious.add(`${affected.package.name}@${v}`); + } + } + } + } + } catch { /* skip invalid lines */ } + } + return malicious; + } catch { + return new Set(); + } +} + +async function fetchGitHubAdvisory() { + try { + const response = await fetch('https://api.github.com/advisories?ecosystem=npm&type=malware&per_page=100', { + headers: { 'Accept': 'application/vnd.github+json' }, + signal: AbortSignal.timeout(10000) + }); + if (!response.ok) return new Set(); + + const advisories = await response.json(); + const malicious = new Set(); + + for (const advisory of advisories) { + if (advisory.vulnerabilities) { + for (const vuln of advisory.vulnerabilities) { + if (vuln.package?.ecosystem === 'npm' && vuln.package?.name) { + // GitHub uses version ranges, we'll mark the package name + // and check ranges in the scan + if (vuln.vulnerable_version_range) { + malicious.add(`${vuln.package.name}:${vuln.vulnerable_version_range}`); + } + } + } + } + } + return malicious; + } catch { + return new Set(); + } +} + +function parseVersionRange(range, version) { + // Simple version range parser for GitHub advisory format + // Handles: "= 1.0.0", "< 1.0.0", "<= 1.0.0", "> 1.0.0", ">= 1.0.0" + if (!range || !version) return false; + + const parts = range.split(',').map(p => p.trim()); + for (const part of parts) { + const match = part.match(/^([<>=]+)\s*(.+)$/); + if (!match) { + if (part === version) return true; + continue; + } + const [, op, rangeVer] = match; + const cmp = compareVersions(version, rangeVer); + + if (op === '=' && cmp !== 0) return false; + if (op === '<' && cmp >= 0) return false; + if (op === '<=' && cmp > 0) return false; + if (op === '>' && cmp <= 0) return false; + if (op === '>=' && cmp < 0) return false; + } + return true; +} + +function compareVersions(a, b) { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const na = pa[i] || 0; + const nb = pb[i] || 0; + if (na > nb) return 1; + if (na < nb) return -1; + } + return 0; +} + +function extractVersionNumber(versionSpec) { + if (!versionSpec) return null; + // Remove ^, ~, >=, <=, >, <, = prefixes + const match = versionSpec.match(/[\d]+\.[\d]+\.[\d]+(?:-[\w.]+)?/); + return match ? match[0] : null; +} + +function getPackagesFromPackageJson() { + const packageJsonPath = join(ROOT_DIR, 'package.json'); + if (!existsSync(packageJsonPath)) return []; + + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + const packages = []; + + const depTypes = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']; + for (const depType of depTypes) { + const deps = packageJson[depType] || {}; + for (const [name, versionSpec] of Object.entries(deps)) { + // Skip file: and link: dependencies + if (typeof versionSpec === 'string' && !versionSpec.startsWith('file:') && !versionSpec.startsWith('link:')) { + const version = extractVersionNumber(versionSpec); + if (version) { + packages.push({ name, version, source: 'package.json' }); + } + } + } + } + + return packages; +} + +function getPackagesFromLockfile() { + const lockfilePath = join(ROOT_DIR, 'package-lock.json'); + if (!existsSync(lockfilePath)) { + return []; + } + + const lockfile = JSON.parse(readFileSync(lockfilePath, 'utf8')); + const packages = []; + + // npm v2+ lockfile format + if (lockfile.packages) { + for (const [path, info] of Object.entries(lockfile.packages)) { + if (!path || path === '') continue; // skip root + const name = path.replace(/^node_modules\//, '').replace(/\/node_modules\//g, '/'); + if (info.version) { + packages.push({ name, version: info.version, source: 'lockfile' }); + } + } + } + + // npm v1 lockfile format + if (lockfile.dependencies) { + const extractDeps = (deps, prefix = '') => { + for (const [name, info] of Object.entries(deps)) { + const fullName = prefix ? `${prefix}/${name}` : name; + if (info.version) { + packages.push({ name: fullName, version: info.version, source: 'lockfile' }); + } + if (info.dependencies) { + extractDeps(info.dependencies, fullName); + } + } + }; + extractDeps(lockfile.dependencies); + } + + return packages; +} + +function getAllPackages() { + const packageJsonPkgs = getPackagesFromPackageJson(); + const lockfilePkgs = getPackagesFromLockfile(); + + // Combine and dedupe (lockfile takes precedence for same name) + const seen = new Map(); + for (const pkg of lockfilePkgs) { + seen.set(`${pkg.name}@${pkg.version}`, pkg); + } + for (const pkg of packageJsonPkgs) { + const key = `${pkg.name}@${pkg.version}`; + if (!seen.has(key)) { + seen.set(key, pkg); + } + } + + return [...seen.values()]; +} + +async function main() { + console.log('\nšŸ”’ ADF SECURITY CHECK'); + console.log('='.repeat(70)); + console.log('Scanning for known supply chain attacks and compromised packages...\n'); + + // Get all packages from both package.json and lockfile + const packages = getAllPackages(); + if (packages.length === 0) { + console.log(' āš ļø No packages found to check'); + console.log('='.repeat(70) + '\n'); + process.exit(0); + } + + const fromPackageJson = packages.filter(p => p.source === 'package.json').length; + const fromLockfile = packages.filter(p => p.source === 'lockfile').length; + console.log(` Checking ${packages.length} packages (${fromPackageJson} from package.json, ${fromLockfile} from lockfile)\n`); + + // Try to use cache first + let threats = readCache(); + let fromCache = true; + + if (!threats) { + fromCache = false; + console.log(' Fetching latest security databases...\n'); + + const [osvThreats, ghThreats] = await Promise.all([ + fetchOSV().then(r => { console.log(` šŸ“” OSV: ${r.size} malware entries`); return r; }), + fetchGitHubAdvisory().then(r => { console.log(` šŸ“” GitHub Advisory: ${r.size} malware entries`); return r; }) + ]); + + threats = new Set([...KNOWN_MALICIOUS, ...osvThreats]); + + // Store GitHub advisories separately (they have version ranges) + const ghRanges = [...ghThreats]; + + // Check packages against exact matches and ranges + const found = []; + + for (const pkg of packages) { + const exact = `${pkg.name}@${pkg.version}`; + + // Check exact match + if (threats.has(exact)) { + found.push({ ...pkg, source: 'exact match' }); + continue; + } + + // Check GitHub Advisory ranges + for (const entry of ghRanges) { + const [name, range] = entry.split(':'); + if (pkg.name === name && parseVersionRange(range, pkg.version)) { + found.push({ ...pkg, source: 'GitHub Advisory' }); + break; + } + } + } + + if (found.length > 0) { + console.log('\n' + '!'.repeat(70)); + console.log('🚨 MALICIOUS PACKAGES DETECTED - BLOCKING INSTALLATION'); + console.log('!'.repeat(70) + '\n'); + + for (const pkg of found) { + console.log(` āŒ ${pkg.name}@${pkg.version} (${pkg.source})`); + } + + console.log('\nThese packages are known to contain malware or malicious code.'); + console.log('Installation has been blocked to protect your system.\n'); + console.log('Actions:'); + console.log(' 1. Remove these packages from package.json'); + console.log(' 2. Find safe alternatives'); + console.log(' 3. Run npm install again\n'); + console.log('='.repeat(70) + '\n'); + + process.exit(1); + } + + // Cache the results + writeCache(threats); + console.log(`\nāœ… Security check passed (${threats.size + ghRanges.length} known threats checked)`); + } else { + // Quick check against cached threats + const found = []; + for (const pkg of packages) { + const exact = `${pkg.name}@${pkg.version}`; + if (threats.has(exact)) { + found.push(pkg); + } + } + + if (found.length > 0) { + console.log('\n' + '!'.repeat(70)); + console.log('🚨 MALICIOUS PACKAGES DETECTED - BLOCKING INSTALLATION'); + console.log('!'.repeat(70) + '\n'); + + for (const pkg of found) { + console.log(` āŒ ${pkg.name}@${pkg.version}`); + } + + console.log('\n='.repeat(70) + '\n'); + process.exit(1); + } + + console.log(`āœ… Security check passed (cached, ${threats.size} known threats)`); + } + + console.log('='.repeat(70) + '\n'); +} + +main().catch(err => { + console.error('Security check error:', err.message); + // Don't block on errors - allow install to proceed + process.exit(0); +});