From cb34db59d788e1ed4a1ad9b298bdb6915b46ce65 Mon Sep 17 00:00:00 2001 From: VitoAlbano Date: Tue, 2 Jun 2026 17:06:51 +0100 Subject: [PATCH] [ci:force] - Fixed complexity of the scripts --- .github/workflows/pull-request.yml | 14 +- .../supply-chain-pr-instructions.yml | 3 +- scripts/check-new-packages.mjs | 161 +--------- scripts/post-install-check.mjs | 162 ++-------- scripts/safe-add.mjs | 183 ++++++----- scripts/security/lockfile-parser.mjs | 111 +++++++ scripts/security/malware-checker.mjs | 294 ++++++++++++++++++ scripts/security/report-printer.mjs | 95 ++++++ 8 files changed, 650 insertions(+), 373 deletions(-) create mode 100644 scripts/security/lockfile-parser.mjs create mode 100644 scripts/security/malware-checker.mjs create mode 100644 scripts/security/report-printer.mjs diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index d4bca92f3d..bb5b2f8cd0 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -46,12 +46,18 @@ jobs: - name: Ensure SHA pinned actions uses: hyland/github-actions-ensure-sha-pinned-actions@7d494b6b53e71f75194d74b4dd890a2266d060b5 # v2.0.0 - - name: Check package-lock.json version + - name: Check pnpm-lock.yaml version run: | - if [[ $(jq '.lockfileVersion == 3' package-lock.json) == "true" ]] ; then - echo "package-lock.json has a correct version" + if [[ -f "pnpm-lock.yaml" ]]; then + LOCKFILE_VERSION=$(grep "^lockfileVersion:" pnpm-lock.yaml | cut -d"'" -f2) + if [[ "$LOCKFILE_VERSION" == "9.0" ]]; then + echo "pnpm-lock.yaml has correct version: $LOCKFILE_VERSION" + else + echo "pnpm-lock.yaml must be version 9.0, found: $LOCKFILE_VERSION" + exit 1 + fi else - echo "package-lock must be version 3" + echo "pnpm-lock.yaml is missing" exit 1 fi diff --git a/.github/workflows/supply-chain-pr-instructions.yml b/.github/workflows/supply-chain-pr-instructions.yml index 258959d46c..2a2a88ee33 100644 --- a/.github/workflows/supply-chain-pr-instructions.yml +++ b/.github/workflows/supply-chain-pr-instructions.yml @@ -5,9 +5,8 @@ on: types: [opened, reopened, synchronize] paths: - "package.json" - - "package-lock.json" + - "pnpm-lock.yaml" - "**/package.json" - - "**/package-lock.json" - "pom.xml" - "**/pom.xml" diff --git a/scripts/check-new-packages.mjs b/scripts/check-new-packages.mjs index fd5ce6421e..5f09ca50f3 100644 --- a/scripts/check-new-packages.mjs +++ b/scripts/check-new-packages.mjs @@ -18,166 +18,37 @@ */ /** - * Pre-commit security check for new packages. + * Pre-commit security check. * - * Checks any new/changed packages in pnpm-lock.yaml against OSV and GitHub - * Advisory databases to prevent committing known malicious packages. + * Runs via husky pre-commit hook. + * Blocks commits containing known malicious packages. */ -import { execSync } from 'node:child_process'; - -const MALWARE_KEYWORDS = ['malware', 'malicious', 'compromised', 'supply chain', 'backdoor']; - -function getChangedPackages() { - try { - const diff = execSync('git diff --cached pnpm-lock.yaml', { encoding: 'utf-8' }); - - const addedPackages = []; - const packageRegex = /^\+\s+'?([^:'\s]+)'?:\s*$/gm; - const versionRegex = /^\+\s+version:\s+'?([^'\s]+)'?/gm; - - let match; - const lines = diff.split('\n'); - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (line.startsWith('+') && !line.startsWith('+++')) { - const pkgMatch = line.match(/^\+\s+'?\/([^']+)'?:/); - if (pkgMatch) { - const fullPath = pkgMatch[1]; - const name = fullPath.includes('@') && !fullPath.startsWith('@') - ? fullPath.split('@')[0] - : fullPath.replace(/@[^/]+$/, ''); - const version = fullPath.match(/@(\d+\.\d+\.\d+[^/]*)/)?.[1]; - if (name && version) { - addedPackages.push({ name, version }); - } - } - } - } - - return addedPackages; - } catch { - return []; - } -} - -async function checkWithOSV(packages) { - if (!packages.length) return []; - - const findings = []; - - try { - const response = await fetch('https://api.osv.dev/v1/querybatch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - queries: packages.map(pkg => ({ - package: { name: pkg.name, ecosystem: 'npm' }, - version: pkg.version - })) - }), - signal: AbortSignal.timeout(30000) - }); - - if (!response.ok) return findings; - - const data = await response.json(); - - for (let i = 0; i < (data.results || []).length; i++) { - const result = data.results[i]; - for (const vuln of result.vulns || []) { - const summary = [vuln.summary || '', vuln.details || ''].join(' ').toLowerCase(); - if (MALWARE_KEYWORDS.some(kw => summary.includes(kw))) { - findings.push({ - package: packages[i].name, - version: packages[i].version, - reason: vuln.summary || 'Malware detected', - source: 'OSV' - }); - } - } - } - } catch { - // Network errors are non-fatal - } - - return findings; -} - -async function checkWithGitHub(packages) { - if (!packages.length) return []; - - const findings = []; - - 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 findings; - - const advisories = await response.json(); - - for (const pkg of packages) { - for (const advisory of advisories) { - for (const vuln of advisory.vulnerabilities || []) { - if (vuln.package?.name === pkg.name) { - findings.push({ - package: pkg.name, - version: pkg.version, - reason: advisory.summary || 'Malware advisory', - source: 'GitHub Advisory' - }); - } - } - } - } - } catch { - // Network errors are non-fatal - } - - return findings; -} +import { readChangedPackagesFromGitDiff } from './security/lockfile-parser.mjs'; +import { checkPackagesForMalware } from './security/malware-checker.mjs'; +import { + printCheckingMessage, + printCommitBlockedWarning, + printAllPackagesClean +} from './security/report-printer.mjs'; async function main() { - const changedPackages = getChangedPackages(); + const changedPackages = readChangedPackagesFromGitDiff(); if (!changedPackages.length) { process.exit(0); } - console.log(`\nšŸ” Checking ${changedPackages.length} new/changed packages against security databases...\n`); + printCheckingMessage(changedPackages.length); - const [osvFindings, ghFindings] = await Promise.all([ - checkWithOSV(changedPackages), - checkWithGitHub(changedPackages) - ]); - - const allFindings = [...osvFindings, ...ghFindings]; - - if (allFindings.length > 0) { - console.error('='.repeat(70)); - console.error('🚨 MALICIOUS PACKAGES DETECTED - COMMIT BLOCKED'); - console.error('='.repeat(70) + '\n'); - - for (const finding of allFindings) { - console.error(` āŒ ${finding.package}@${finding.version}`); - console.error(` ${finding.reason} (${finding.source})\n`); - } - - console.error('Remove these packages before committing:'); - console.error(' pnpm remove \n'); - console.error('='.repeat(70) + '\n'); + const malwareFindings = await checkPackagesForMalware(changedPackages); + if (malwareFindings.length > 0) { + printCommitBlockedWarning(malwareFindings); process.exit(1); } - console.log('āœ… All new packages passed security checks\n'); + printAllPackagesClean(); } try { diff --git a/scripts/post-install-check.mjs b/scripts/post-install-check.mjs index 15bcde99d0..c248eb92b5 100644 --- a/scripts/post-install-check.mjs +++ b/scripts/post-install-check.mjs @@ -3,161 +3,49 @@ /*! * @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. */ /** - * Post-install security check - warns if any installed packages are known malware. + * Post-install security check. + * * Runs after pnpm install via the prepare hook. + * Warns if any installed packages are known malware. */ -import { readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { readAllPackagesFromLockfile } from './security/lockfile-parser.mjs'; +import { checkPackagesForMalware } from './security/malware-checker.mjs'; +import { printMalwareWarning } from './security/report-printer.mjs'; + const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT_DIR = join(__dirname, '..'); -const MALWARE_KEYWORDS = ['malware', 'malicious', 'compromised', 'supply chain', 'backdoor']; - -function getInstalledPackages() { - try { - const lockfile = readFileSync(join(ROOT_DIR, 'pnpm-lock.yaml'), 'utf-8'); - const packages = []; - const regex = /^\s+'?\/([^'(]+)'/gm; - let match; - - while ((match = regex.exec(lockfile)) !== null) { - const fullPath = match[1]; - const lastAt = fullPath.lastIndexOf('@'); - if (lastAt > 0) { - const name = fullPath.substring(0, lastAt); - const version = fullPath.substring(lastAt + 1); - packages.push({ name, version }); - } - } - - return packages; - } catch { - return []; - } -} - -async function checkWithOSV(packages) { - const findings = []; - const batches = []; - - for (let i = 0; i < packages.length; i += 1000) { - batches.push(packages.slice(i, i + 1000)); - } - - for (const batch of batches) { - try { - const response = await fetch('https://api.osv.dev/v1/querybatch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - queries: batch.map(pkg => ({ - package: { name: pkg.name, ecosystem: 'npm' }, - version: pkg.version - })) - }), - signal: AbortSignal.timeout(30000) - }); - - if (!response.ok) continue; - - const data = await response.json(); - for (let i = 0; i < (data.results || []).length; i++) { - for (const vuln of data.results[i].vulns || []) { - const summary = [vuln.summary || '', vuln.details || ''].join(' ').toLowerCase(); - if (MALWARE_KEYWORDS.some(kw => summary.includes(kw))) { - findings.push({ - name: batch[i].name, - version: batch[i].version, - reason: vuln.summary || 'Malware detected', - source: 'OSV' - }); - } - } - } - } catch {} - } - - return findings; -} - -async function checkWithGitHub(packages) { - const findings = []; - - 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 findings; - - const advisories = await response.json(); - const malwareNames = new Set(); - - for (const advisory of advisories) { - for (const vuln of advisory.vulnerabilities || []) { - if (vuln.package?.name) { - malwareNames.add(vuln.package.name); - } - } - } - - for (const pkg of packages) { - if (malwareNames.has(pkg.name)) { - findings.push({ - name: pkg.name, - version: pkg.version, - reason: 'Known malware package', - source: 'GitHub Advisory' - }); - } - } - } catch {} - - return findings; -} +const LOCKFILE_PATH = join(ROOT_DIR, 'pnpm-lock.yaml'); async function main() { - const packages = getInstalledPackages(); + const installedPackages = readAllPackagesFromLockfile(LOCKFILE_PATH); - if (!packages.length) { + if (!installedPackages.length) { return; } - const [osvFindings, ghFindings] = await Promise.all([ - checkWithOSV(packages), - checkWithGitHub(packages) - ]); - - const allFindings = [...osvFindings, ...ghFindings]; - - // Dedupe - const unique = allFindings.filter((f, i, arr) => - arr.findIndex(x => x.name === f.name && x.version === f.version) === i - ); - - if (unique.length > 0) { - console.error('\n' + '!'.repeat(70)); - console.error('🚨 WARNING: MALICIOUS PACKAGES DETECTED'); - console.error('!'.repeat(70) + '\n'); - - for (const finding of unique) { - console.error(` āŒ ${finding.name}@${finding.version}`); - console.error(` ${finding.reason} (${finding.source})\n`); - } - - console.error('Remove these packages immediately:'); - console.error(' pnpm remove \n'); - console.error('!'.repeat(70) + '\n'); + const malwareFindings = await checkPackagesForMalware(installedPackages); + if (malwareFindings.length > 0) { + printMalwareWarning(malwareFindings); process.exit(1); } } diff --git a/scripts/safe-add.mjs b/scripts/safe-add.mjs index 9980f1aeb7..9d8ce8ce7c 100644 --- a/scripts/safe-add.mjs +++ b/scripts/safe-add.mjs @@ -18,124 +18,137 @@ */ /** - * Safe add - checks packages against security databases before installing. + * Safe add package. * + * Checks packages against security databases before installing. * Usage: pnpm run add [@version] [-- -D] */ import { execSync } from 'node:child_process'; -const MALWARE_KEYWORDS = ['malware', 'malicious', 'compromised', 'supply chain', 'backdoor']; +import { checkSinglePackageForMalware } from './security/malware-checker.mjs'; +import { + printSecurityCheckHeader, + printPackageClean, + printInstallBlockedWarning +} from './security/report-printer.mjs'; -function parsePackageArg(arg) { - const match = arg.match(/^(@?[^@]+)(?:@(.+))?$/); - return match ? { name: match[1], version: match[2] || 'latest' } : null; +const NPM_REGISTRY_URL = 'https://registry.npmjs.org'; +const REQUEST_TIMEOUT_MS = 5000; + +// ============================================================================ +// ARGUMENT PARSING +// ============================================================================ + +function parsePackageArgument(argument) { + const packageMatch = argument.match(/^(@?[^@]+)(?:@(.+))?$/); + + if (!packageMatch) { + return null; + } + + return { + name: packageMatch[1], + version: packageMatch[2] || 'latest' + }; } -async function resolveVersion(name, version) { - if (version !== 'latest') return version; +function separatePackagesAndFlags(args) { + const packageArguments = args.filter(arg => !arg.startsWith('-')); + const flagArguments = args.filter(arg => arg.startsWith('-')); - try { - const response = await fetch(`https://registry.npmjs.org/${name}/latest`, { - signal: AbortSignal.timeout(5000) - }); - if (response.ok) { - const data = await response.json(); - return data.version; - } - } catch {} - return null; + return { + packages: packageArguments, + flags: flagArguments.join(' ') + }; } -async function checkOSV(name, version) { +// ============================================================================ +// VERSION RESOLUTION +// ============================================================================ + +async function fetchLatestVersion(packageName) { try { - const response = await fetch('https://api.osv.dev/v1/query', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ package: { name, ecosystem: 'npm' }, version }), - signal: AbortSignal.timeout(10000) + const response = await fetch(`${NPM_REGISTRY_URL}/${packageName}/latest`, { + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); - if (!response.ok) return null; - - const data = await response.json(); - for (const vuln of data.vulns || []) { - const summary = [vuln.summary || '', vuln.details || ''].join(' ').toLowerCase(); - if (MALWARE_KEYWORDS.some(kw => summary.includes(kw))) { - return vuln.summary || 'Malware detected'; - } + if (!response.ok) { + return null; } - } catch {} - return null; + + const packageData = await response.json(); + return packageData.version; + } catch { + return null; + } } -async function checkGitHub(name) { - 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) - } - ); +async function resolvePackageVersion(packageName, requestedVersion) { + if (requestedVersion !== 'latest') { + return requestedVersion; + } - if (!response.ok) return null; + return fetchLatestVersion(packageName); +} - const advisories = await response.json(); - for (const advisory of advisories) { - for (const vuln of advisory.vulnerabilities || []) { - if (vuln.package?.name === name) { - return advisory.summary || 'Malware advisory'; - } - } - } - } catch {} - return null; +// ============================================================================ +// PACKAGE INSTALLATION +// ============================================================================ + +function installPackages(packageNames, flags) { + const packageList = packageNames.join(' '); + const installCommand = `pnpm add ${packageList} ${flags}`.trim(); + + execSync(installCommand, { stdio: 'inherit' }); +} + +// ============================================================================ +// MAIN +// ============================================================================ + +async function validateAndCheckPackage(packageArgument) { + const parsedPackage = parsePackageArgument(packageArgument); + + if (!parsedPackage) { + console.error(`Invalid package argument: ${packageArgument}`); + process.exit(1); + } + + const resolvedVersion = await resolvePackageVersion(parsedPackage.name, parsedPackage.version); + + if (!resolvedVersion) { + console.error(`Could not resolve version for ${parsedPackage.name}`); + process.exit(1); + } + + const malwareReason = await checkSinglePackageForMalware(parsedPackage.name, resolvedVersion); + + if (malwareReason) { + printInstallBlockedWarning(parsedPackage.name, resolvedVersion, malwareReason); + process.exit(1); + } + + printPackageClean(parsedPackage.name, resolvedVersion); } async function main() { - const args = process.argv.slice(2); - const packages = args.filter(arg => !arg.startsWith('-')); - const flags = args.filter(arg => arg.startsWith('-')).join(' '); + const commandLineArgs = process.argv.slice(2); + const { packages, flags } = separatePackagesAndFlags(commandLineArgs); if (!packages.length) { console.log('Usage: pnpm run add [@version] [-- -D]'); process.exit(1); } - console.log('\nšŸ”’ Checking packages against security databases...\n'); + printSecurityCheckHeader(); - for (const arg of packages) { - const pkg = parsePackageArg(arg); - if (!pkg) { - console.error(`Invalid package: ${arg}`); - process.exit(1); - } - - const version = await resolveVersion(pkg.name, pkg.version); - if (!version) { - console.error(`Could not resolve version for ${pkg.name}`); - process.exit(1); - } - - console.log(` šŸ“¦ ${pkg.name}@${version}`); - - const [osvResult, ghResult] = await Promise.all([ - checkOSV(pkg.name, version), - checkGitHub(pkg.name) - ]); - - if (osvResult || ghResult) { - console.error(`\nāŒ BLOCKED: ${pkg.name}@${version}`); - console.error(` ${osvResult || ghResult}\n`); - process.exit(1); - } - - console.log(` āœ… Clean\n`); + for (const packageArgument of packages) { + await validateAndCheckPackage(packageArgument); } console.log('Installing...\n'); - execSync(`pnpm add ${packages.join(' ')} ${flags}`, { stdio: 'inherit' }); + installPackages(packages, flags); } try { diff --git a/scripts/security/lockfile-parser.mjs b/scripts/security/lockfile-parser.mjs new file mode 100644 index 0000000000..7d95e8a0aa --- /dev/null +++ b/scripts/security/lockfile-parser.mjs @@ -0,0 +1,111 @@ +/*! + * @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. + */ + +/** + * Lockfile parser - extracts package information from pnpm-lock.yaml. + */ + +import { readFileSync } from 'node:fs'; +import { execSync } from 'node:child_process'; + +// ============================================================================ +// PACKAGE NAME PARSING +// ============================================================================ + +function parsePackagePathEntry(fullPath) { + const lastAtIndex = fullPath.lastIndexOf('@'); + + if (lastAtIndex <= 0) { + return null; + } + + const packageName = fullPath.substring(0, lastAtIndex); + const packageVersion = fullPath.substring(lastAtIndex + 1); + + return { name: packageName, version: packageVersion }; +} + +function parsePackageFromDiffLine(line) { + const packagePathMatch = line.match(/^\+\s+'?\/([^']+)'?:/); + + if (!packagePathMatch) { + return null; + } + + const fullPath = packagePathMatch[1]; + const versionMatch = fullPath.match(/@(\d+\.\d+\.\d+[^/]*)/); + + if (!versionMatch) { + return null; + } + + const isScoped = fullPath.startsWith('@'); + const packageName = isScoped + ? fullPath.replace(/@[^/]+$/, '') + : fullPath.split('@')[0]; + + return { name: packageName, version: versionMatch[1] }; +} + +// ============================================================================ +// LOCKFILE READING +// ============================================================================ + +export function readAllPackagesFromLockfile(lockfilePath) { + try { + const lockfileContent = readFileSync(lockfilePath, 'utf-8'); + const packages = []; + const packagePathRegex = /^\s+'?\/([^'(]+)'/gm; + + let match; + while ((match = packagePathRegex.exec(lockfileContent)) !== null) { + const parsedPackage = parsePackagePathEntry(match[1]); + + if (parsedPackage) { + packages.push(parsedPackage); + } + } + + return packages; + } catch { + return []; + } +} + +export function readChangedPackagesFromGitDiff() { + try { + const diffOutput = execSync('git diff --cached pnpm-lock.yaml', { encoding: 'utf-8' }); + const changedPackages = []; + const diffLines = diffOutput.split('\n'); + + for (const line of diffLines) { + const isAddedLine = line.startsWith('+') && !line.startsWith('+++'); + + if (isAddedLine) { + const parsedPackage = parsePackageFromDiffLine(line); + + if (parsedPackage) { + changedPackages.push(parsedPackage); + } + } + } + + return changedPackages; + } catch { + return []; + } +} diff --git a/scripts/security/malware-checker.mjs b/scripts/security/malware-checker.mjs new file mode 100644 index 0000000000..ae87515e38 --- /dev/null +++ b/scripts/security/malware-checker.mjs @@ -0,0 +1,294 @@ +/*! + * @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. + */ + +/** + * Malware checker - queries OSV and GitHub Advisory databases for known malware. + */ + +const MALWARE_KEYWORDS = ['malware', 'malicious', 'compromised', 'supply chain', 'backdoor']; +const OSV_API_URL = 'https://api.osv.dev/v1/querybatch'; +const OSV_SINGLE_API_URL = 'https://api.osv.dev/v1/query'; +const GITHUB_API_URL = 'https://api.github.com/advisories?ecosystem=npm&type=malware&per_page=100'; +const REQUEST_TIMEOUT_MS = 10000; +const BATCH_SIZE = 1000; + +// ============================================================================ +// UTILITY FUNCTIONS +// ============================================================================ + +function containsMalwareKeyword(text) { + const lowerText = text.toLowerCase(); + return MALWARE_KEYWORDS.some(keyword => lowerText.includes(keyword)); +} + +function splitIntoBatches(items, batchSize) { + const batches = []; + for (let index = 0; index < items.length; index += batchSize) { + batches.push(items.slice(index, index + batchSize)); + } + return batches; +} + +function deduplicateFindings(findings) { + return findings.filter((finding, index, array) => + array.findIndex(other => + other.name === finding.name && other.version === finding.version + ) === index + ); +} + +// ============================================================================ +// OSV API +// ============================================================================ + +function buildOsvQuery(packageName, packageVersion) { + return { + package: { name: packageName, ecosystem: 'npm' }, + version: packageVersion + }; +} + +function buildOsvBatchPayload(packages) { + return { + queries: packages.map(pkg => buildOsvQuery(pkg.name, pkg.version)) + }; +} + +function extractMalwareFromOsvVulnerability(vulnerability) { + const summaryText = [vulnerability.summary || '', vulnerability.details || ''].join(' '); + + if (containsMalwareKeyword(summaryText)) { + return vulnerability.summary || 'Malware detected'; + } + + return null; +} + +async function fetchOsvBatch(packages) { + const response = await fetch(OSV_API_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(buildOsvBatchPayload(packages)), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS * 3) + }); + + if (!response.ok) { + return null; + } + + return response.json(); +} + +async function fetchOsvSingle(packageName, packageVersion) { + const response = await fetch(OSV_SINGLE_API_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(buildOsvQuery(packageName, packageVersion)), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }); + + if (!response.ok) { + return null; + } + + return response.json(); +} + +function processBatchResults(batchResults, packages) { + const findings = []; + const results = batchResults.results || []; + + for (let index = 0; index < results.length; index++) { + const vulnerabilities = results[index].vulns || []; + + for (const vulnerability of vulnerabilities) { + const malwareReason = extractMalwareFromOsvVulnerability(vulnerability); + + if (malwareReason) { + findings.push({ + name: packages[index].name, + version: packages[index].version, + reason: malwareReason, + source: 'OSV' + }); + } + } + } + + return findings; +} + +export async function checkPackagesWithOsv(packages) { + if (!packages.length) { + return []; + } + + const allFindings = []; + const batches = splitIntoBatches(packages, BATCH_SIZE); + + for (const batch of batches) { + try { + const batchResults = await fetchOsvBatch(batch); + + if (batchResults) { + const findings = processBatchResults(batchResults, batch); + allFindings.push(...findings); + } + } catch { + // Network errors are non-fatal + } + } + + return allFindings; +} + +export async function checkSinglePackageWithOsv(packageName, packageVersion) { + try { + const data = await fetchOsvSingle(packageName, packageVersion); + + if (!data) { + return null; + } + + for (const vulnerability of data.vulns || []) { + const malwareReason = extractMalwareFromOsvVulnerability(vulnerability); + + if (malwareReason) { + return malwareReason; + } + } + } catch { + // Network errors are non-fatal + } + + return null; +} + +// ============================================================================ +// GITHUB ADVISORY API +// ============================================================================ + +async function fetchGitHubAdvisories() { + const response = await fetch(GITHUB_API_URL, { + headers: { 'Accept': 'application/vnd.github+json' }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }); + + if (!response.ok) { + return null; + } + + return response.json(); +} + +function extractMalwarePackageNames(advisories) { + const malwarePackageNames = new Set(); + + for (const advisory of advisories) { + const vulnerabilities = advisory.vulnerabilities || []; + + for (const vulnerability of vulnerabilities) { + const packageName = vulnerability.package?.name; + + if (packageName) { + malwarePackageNames.add(packageName); + } + } + } + + return malwarePackageNames; +} + +export async function checkPackagesWithGitHub(packages) { + if (!packages.length) { + return []; + } + + const findings = []; + + try { + const advisories = await fetchGitHubAdvisories(); + + if (!advisories) { + return findings; + } + + const malwarePackageNames = extractMalwarePackageNames(advisories); + + for (const pkg of packages) { + if (malwarePackageNames.has(pkg.name)) { + findings.push({ + name: pkg.name, + version: pkg.version, + reason: 'Known malware package', + source: 'GitHub Advisory' + }); + } + } + } catch { + // Network errors are non-fatal + } + + return findings; +} + +export async function checkSinglePackageWithGitHub(packageName) { + try { + const advisories = await fetchGitHubAdvisories(); + + if (!advisories) { + return null; + } + + for (const advisory of advisories) { + const vulnerabilities = advisory.vulnerabilities || []; + + for (const vulnerability of vulnerabilities) { + if (vulnerability.package?.name === packageName) { + return advisory.summary || 'Malware advisory'; + } + } + } + } catch { + // Network errors are non-fatal + } + + return null; +} + +// ============================================================================ +// COMBINED CHECK +// ============================================================================ + +export async function checkPackagesForMalware(packages) { + const [osvFindings, githubFindings] = await Promise.all([ + checkPackagesWithOsv(packages), + checkPackagesWithGitHub(packages) + ]); + + const allFindings = [...osvFindings, ...githubFindings]; + return deduplicateFindings(allFindings); +} + +export async function checkSinglePackageForMalware(packageName, packageVersion) { + const [osvResult, githubResult] = await Promise.all([ + checkSinglePackageWithOsv(packageName, packageVersion), + checkSinglePackageWithGitHub(packageName) + ]); + + return osvResult || githubResult || null; +} diff --git a/scripts/security/report-printer.mjs b/scripts/security/report-printer.mjs new file mode 100644 index 0000000000..a26cc1556e --- /dev/null +++ b/scripts/security/report-printer.mjs @@ -0,0 +1,95 @@ +/*! + * @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. + */ + +/** + * Report printer - formats and prints security findings to console. + */ + +const SEPARATOR_CHAR = '='; +const WARNING_CHAR = '!'; +const SEPARATOR_WIDTH = 70; + +// ============================================================================ +// FORMATTING +// ============================================================================ + +function createSeparator(char = SEPARATOR_CHAR) { + return char.repeat(SEPARATOR_WIDTH); +} + +function formatPackageIdentifier(packageName, packageVersion) { + return `${packageName}@${packageVersion}`; +} + +function formatFindingLine(finding) { + const packageId = formatPackageIdentifier(finding.name, finding.version); + return ` āŒ ${packageId}\n ${finding.reason} (${finding.source})`; +} + +// ============================================================================ +// REPORT PRINTING +// ============================================================================ + +export function printMalwareWarning(findings) { + console.error('\n' + createSeparator(WARNING_CHAR)); + console.error('🚨 WARNING: MALICIOUS PACKAGES DETECTED'); + console.error(createSeparator(WARNING_CHAR) + '\n'); + + for (const finding of findings) { + console.error(formatFindingLine(finding) + '\n'); + } + + console.error('Remove these packages immediately:'); + console.error(' pnpm remove \n'); + console.error(createSeparator(WARNING_CHAR) + '\n'); +} + +export function printCommitBlockedWarning(findings) { + console.error(createSeparator()); + console.error('🚨 MALICIOUS PACKAGES DETECTED - COMMIT BLOCKED'); + console.error(createSeparator() + '\n'); + + for (const finding of findings) { + console.error(formatFindingLine(finding) + '\n'); + } + + console.error('Remove these packages before committing:'); + console.error(' pnpm remove \n'); + console.error(createSeparator() + '\n'); +} + +export function printInstallBlockedWarning(packageName, packageVersion, reason) { + console.error(`\nāŒ BLOCKED: ${formatPackageIdentifier(packageName, packageVersion)}`); + console.error(` ${reason}\n`); +} + +export function printCheckingMessage(packageCount) { + console.log(`\nšŸ” Checking ${packageCount} packages against security databases...\n`); +} + +export function printPackageClean(packageName, packageVersion) { + console.log(` šŸ“¦ ${formatPackageIdentifier(packageName, packageVersion)}`); + console.log(` āœ… Clean\n`); +} + +export function printAllPackagesClean() { + console.log('āœ… All new packages passed security checks\n'); +} + +export function printSecurityCheckHeader() { + console.log('\nšŸ”’ Checking packages against security databases...\n'); +}