From 74248cd74a5bf13480673a37b44a301bfd2f29d7 Mon Sep 17 00:00:00 2001 From: Sahil Bansal Date: Fri, 4 Jul 2025 12:42:37 +0530 Subject: [PATCH] Publish `pmg` to npm (#50) * feat: pmg publish to npm * feat: add github action for publishing to npm * Update publish/npm/install.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Sahil Bansal * Update .github/workflows/publish-npm.yml Co-authored-by: Omkar Phansopkar Signed-off-by: Sahil Bansal * refactor: define constants * refactor: improve npm package reliability with removing dynamic handlers and better error handling and validation * fix: publish-npm workflow * Update publish/npm/test.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Sahil Bansal * refactor(npm-workflow): use a more portable loop construct * refactor: remove version field from package.json * feat: use os.tmpdir() with mkdtempSync for cleaner temp directory handling * fix: add redirect limit and cleanup to prevent infinite redirects and resource leaks * remove test.js for npm publisher pack * chore: test cmd cleanup --------- Signed-off-by: Sahil Bansal Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Omkar Phansopkar --- .github/workflows/publish-npm.yml | 97 +++++++++++++ publish/npm/.npmignore | 17 +++ publish/npm/README.md | 60 ++++++++ publish/npm/bin/pmg.js | 76 ++++++++++ publish/npm/config.js | 34 +++++ publish/npm/install.js | 224 ++++++++++++++++++++++++++++++ publish/npm/package.json | 57 ++++++++ 7 files changed, 565 insertions(+) create mode 100644 .github/workflows/publish-npm.yml create mode 100644 publish/npm/.npmignore create mode 100644 publish/npm/README.md create mode 100644 publish/npm/bin/pmg.js create mode 100644 publish/npm/config.js create mode 100644 publish/npm/install.js create mode 100644 publish/npm/package.json diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml new file mode 100644 index 0000000..42d8d68 --- /dev/null +++ b/.github/workflows/publish-npm.yml @@ -0,0 +1,97 @@ +name: Publish NPM Package + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + +jobs: + publish-npm: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "18" + registry-url: "https://registry.npmjs.org" + + - name: Extract version from tag + id: version + run: | + echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + + - name: Wait for GitHub release + run: | + echo "Waiting for GitHub release v${{ steps.version.outputs.version }}..." + i=1 + while [ $i -le 30 ]; do + if curl -s -f "https://api.github.com/repos/safedep/pmg/releases/tags/v${{ steps.version.outputs.version }}" > /dev/null; then + echo "Release found!" + break + fi + if [ $i -eq 30 ]; then + echo "Release not found after 10 minutes" + exit 1 + fi + echo "Waiting... ($i/30)" + sleep 20 + i=$((i + 1)) + done + + - name: Prepare package + run: | + cd publish/npm + npm version ${{ steps.version.outputs.version }} --no-git-tag-version + + - name: Publish to npm + run: | + cd publish/npm + npm publish --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + test-installation: + needs: publish-npm + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node-version: ["16", "18", "20"] + + steps: + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Extract version from tag + id: version + run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + + - name: Wait for npm package + run: | + echo "Waiting for npm package..." + i=1 + while [ $i -le 20 ]; do + if npm view @safedep/pmg@${{ steps.version.outputs.version }} > /dev/null 2>&1; then + echo "Package available!" + break + fi + if [ $i -eq 20 ]; then + echo "Package not available after 10 minutes" + exit 1 + fi + echo "Waiting... ($i/20)" + sleep 30 + i=$((i + 1)) + done + + - name: Test installation + run: | + npm install -g @safedep/pmg@${{ steps.version.outputs.version }} + pmg --version + pmg --help || true diff --git a/publish/npm/.npmignore b/publish/npm/.npmignore new file mode 100644 index 0000000..b4e9996 --- /dev/null +++ b/publish/npm/.npmignore @@ -0,0 +1,17 @@ +# Exclude the actual binary (but keep the wrapper script) +bin/pmg +bin/pmg.exe + +# Exclude temp files and directories +temp/ +.temp/ + +# Exclude development files +node_modules/ +.git/ +.gitignore +*.log +.DS_Store + +# Keep only the wrapper script in bin/ +!bin/pmg.js diff --git a/publish/npm/README.md b/publish/npm/README.md new file mode 100644 index 0000000..1644660 --- /dev/null +++ b/publish/npm/README.md @@ -0,0 +1,60 @@ +# PMG - Package Manager Guard + + +🤖 PMG protects developers from getting compromised by malicious open source packages. + +This is the npm distribution of PMG, a tool that wraps your favorite package manager (e.g., `npm`) and blocks malicious packages at install time. + +## Installation + +Install PMG globally via npm: + +```bash +npm install -g @safedep/pmg +``` + +Or using Homebrew: + +```bash +brew tap safedep/tap +brew install safedep/tap/pmg +``` + +## Usage + +Set up PMG to automatically protect your package installations: + +```bash +# Recommended: Set up automatic protection +pmg setup install +``` + +After setup, use your package managers normally: + +```bash +# Your regular commands are now protected +npm install express +pnpm add react +pip install requests +``` + +Or use PMG manually without setup: + +```bash +# Manual protection (alternative) +pmg npm install express +pmg pnpm add react +pmg pip install requests +``` + +## Platform Support + +- ✅ **macOS** (Intel & Apple Silicon) +- ✅ **Linux** (x86_64, ARM64, i386) +- ✅ **Windows** (x86_64, ARM64, i386) + +Requires Node.js 14 or higher. + +--- + +For complete documentation, advanced usage, troubleshooting, and more information, please visit: **[github.com/safedep/pmg](https://github.com/safedep/pmg)** diff --git a/publish/npm/bin/pmg.js b/publish/npm/bin/pmg.js new file mode 100644 index 0000000..0b5ba75 --- /dev/null +++ b/publish/npm/bin/pmg.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const path = require("path"); +const { spawn } = require("child_process"); +const { ORG_NAME, PACKAGE_NAME, BINARY_NAME } = require("../config"); + +const BINARY_NAME_WITH_EXT = + process.platform === "win32" ? `${BINARY_NAME}.exe` : BINARY_NAME; +const BINARY_PATH = path.join(__dirname, BINARY_NAME_WITH_EXT); + +function main() { + // Check if binary exists + if (!fs.existsSync(BINARY_PATH)) { + console.error(`❌ ${BINARY_NAME_WITH_EXT} binary not found`); + console.error( + `Try reinstalling: npm install -g ${ORG_NAME}/${PACKAGE_NAME}`, + ); + process.exit(1); + } + + // Verify binary is executable + try { + fs.accessSync(BINARY_PATH, fs.constants.F_OK | fs.constants.X_OK); + } catch (error) { + console.error(`❌ ${BINARY_NAME_WITH_EXT} is not executable`); + console.error( + `Try reinstalling: npm install -g ${ORG_NAME}/${PACKAGE_NAME}`, + ); + process.exit(1); + } + + // Pass all arguments to the binary + const args = process.argv.slice(2); + + // Spawn the binary with inherited stdio for proper terminal interaction + const child = spawn(BINARY_PATH, args, { + stdio: "inherit", + windowsHide: false, + }); + + // Handle process termination + child.on("error", (error) => { + console.error( + `❌ Failed to execute ${BINARY_NAME_WITH_EXT}: ${error.message}`, + ); + console.error( + `Try reinstalling: npm install -g ${ORG_NAME}/${PACKAGE_NAME}`, + ); + process.exit(1); + }); + + // Exit with the same code as the child process + child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + } else { + process.exit(code || 0); + } + }); + + // Handle termination signals + process.on("SIGTERM", () => { + child.kill("SIGTERM"); + }); + + process.on("SIGINT", () => { + child.kill("SIGINT"); + }); +} + +if (require.main === module) { + main(); +} + +module.exports = { main }; diff --git a/publish/npm/config.js b/publish/npm/config.js new file mode 100644 index 0000000..98f491e --- /dev/null +++ b/publish/npm/config.js @@ -0,0 +1,34 @@ +// Configuration for npm binary wrapper + +const ORG_NAME = "@safedep"; +const PACKAGE_NAME = "pmg"; +const BINARY_NAME = "pmg"; + +// GitHub repository information for releases +const REPO_OWNER = "safedep"; +const REPO_NAME = "pmg"; + +// GitHub releases base URL (constructed from repo info) +const GITHUB_RELEASES_BASE = `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download`; + +// Platform-specific binary filename patterns (GoReleaser format) +const BINARY_PATTERNS = { + "darwin-x64": `${BINARY_NAME}_Darwin_all.tar.gz`, + "darwin-arm64": `${BINARY_NAME}_Darwin_all.tar.gz`, + "linux-x64": `${BINARY_NAME}_Linux_x86_64.tar.gz`, + "linux-arm64": `${BINARY_NAME}_Linux_arm64.tar.gz`, + "linux-ia32": `${BINARY_NAME}_Linux_i386.tar.gz`, + "win32-x64": `${BINARY_NAME}_Windows_x86_64.zip`, + "win32-arm64": `${BINARY_NAME}_Windows_arm64.zip`, + "win32-ia32": `${BINARY_NAME}_Windows_i386.zip`, +}; + +module.exports = { + ORG_NAME, + PACKAGE_NAME, + BINARY_NAME, + REPO_OWNER, + REPO_NAME, + GITHUB_RELEASES_BASE, + BINARY_PATTERNS, +}; diff --git a/publish/npm/install.js b/publish/npm/install.js new file mode 100644 index 0000000..d528f4a --- /dev/null +++ b/publish/npm/install.js @@ -0,0 +1,224 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const https = require("https"); +const crypto = require("crypto"); +const { execSync } = require("child_process"); +const { + BINARY_NAME, + REPO_OWNER, + REPO_NAME, + GITHUB_RELEASES_BASE, + BINARY_PATTERNS, +} = require("./config"); + +// Read version from package.json with strict validation +function getValidatedVersion() { + try { + const packageJson = JSON.parse( + fs.readFileSync(path.join(__dirname, "package.json"), "utf8"), + ); + + const version = packageJson.version; + + // Strict validation: must be valid semver (x.y.z) + if (!/^\d+\.\d+\.\d+$/.test(version)) { + throw new Error(`Invalid version format: ${version}`); + } + + return `v${version}`; + } catch (error) { + throw new Error(`Failed to read valid version: ${error.message}`); + } +} + +const RELEASE_VERSION = getValidatedVersion(); +const BASE_URL = `${GITHUB_RELEASES_BASE}/${RELEASE_VERSION}`; + +// Platform-specific binary URLs (constructed from config) +const BINARY_URLS = {}; +Object.keys(BINARY_PATTERNS).forEach((platform) => { + BINARY_URLS[platform] = `${BASE_URL}/${BINARY_PATTERNS[platform]}`; +}); + +const CHECKSUMS_URL = `${BASE_URL}/checksums.txt`; + +function getPlatformKey() { + const platform = process.platform; + const arch = process.arch; + return `${platform}-${arch}`; +} + +function downloadFile(url, dest, maxRedirects = 5) { + return new Promise((resolve, reject) => { + if (maxRedirects < 0) { + reject(new Error("Too many redirects")); + return; + } + + const file = fs.createWriteStream(dest); + + https + .get(url, (response) => { + if (response.statusCode === 302 || response.statusCode === 301) { + file.close(); + fs.unlink(dest, () => {}); + return downloadFile(response.headers.location, dest, maxRedirects - 1) + .then(resolve) + .catch(reject); + } + + if (response.statusCode !== 200) { + file.close(); + fs.unlink(dest, () => {}); + reject(new Error(`Download failed: ${response.statusCode}`)); + return; + } + + response.pipe(file); + + file.on("finish", () => { + file.close(); + resolve(); + }); + + file.on("error", (err) => { + fs.unlink(dest, () => {}); + reject(err); + }); + }) + .on("error", reject); + }); +} + +function calculateChecksum(filePath) { + const fileBuffer = fs.readFileSync(filePath); + const hashSum = crypto.createHash("sha256"); + hashSum.update(fileBuffer); + return hashSum.digest("hex"); +} + +function validateChecksum(filePath, expectedChecksum) { + const actualChecksum = calculateChecksum(filePath); + return actualChecksum === expectedChecksum; +} + +function extractArchive(archivePath, extractDir) { + const isZip = archivePath.endsWith(".zip"); + + if (isZip) { + execSync(`unzip -o "${archivePath}" -d "${extractDir}"`, { stdio: "pipe" }); + } else { + execSync(`tar -xzf "${archivePath}" -C "${extractDir}"`, { stdio: "pipe" }); + } +} + +async function install() { + let tempWorkspace; + + try { + console.log("📦 Installing PMG binary..."); + + // Get platform-specific URL + const platformKey = getPlatformKey(); + const binaryUrl = BINARY_URLS[platformKey]; + + if (!binaryUrl) { + throw new Error(`Unsupported platform: ${platformKey}`); + } + + console.log(`🔍 Platform: ${platformKey}`); + console.log(`📡 Version: ${RELEASE_VERSION}`); + + // Create directories + const binDir = path.join(__dirname, "bin"); + tempWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "pmg-install-")); + + fs.mkdirSync(binDir, { recursive: true }); + + // Download binary archive + const archiveFilename = path.basename(binaryUrl); + const archivePath = path.join(tempWorkspace, archiveFilename); + + console.log(`⬇️ Downloading binary...`); + await downloadFile(binaryUrl, archivePath); + + // Download checksums + const checksumsPath = path.join(tempWorkspace, "checksums.txt"); + console.log(`⬇️ Downloading checksums...`); + await downloadFile(CHECKSUMS_URL, checksumsPath); + + // Parse checksums file + const checksumsContent = fs.readFileSync(checksumsPath, "utf8"); + const checksumLines = checksumsContent.split("\n"); + + let expectedChecksum = null; + for (const line of checksumLines) { + if (line.includes(archiveFilename)) { + expectedChecksum = line.split(/\s+/)[0]; + break; + } + } + + if (!expectedChecksum) { + throw new Error(`Checksum not found for ${archiveFilename}`); + } + + // Validate checksum + console.log(`🔐 Validating checksum...`); + if (!validateChecksum(archivePath, expectedChecksum)) { + throw new Error( + "Checksum validation failed - binary may be corrupted or tampered", + ); + } + + console.log(`✅ Checksum validated`); + + // Extract archive + console.log(`📂 Extracting binary...`); + extractArchive(archivePath, tempWorkspace); + + // Find and move binary + const binaryName = + process.platform === "win32" ? `${BINARY_NAME}.exe` : BINARY_NAME; + const extractedBinaryPath = path.join(tempWorkspace, binaryName); + const finalBinaryPath = path.join(binDir, binaryName); + + if (!fs.existsSync(extractedBinaryPath)) { + throw new Error( + `Binary not found at expected location: ${extractedBinaryPath}`, + ); + } + + // Move binary to final location + fs.renameSync(extractedBinaryPath, finalBinaryPath); + + // Make executable on Unix systems + if (process.platform !== "win32") { + fs.chmodSync(finalBinaryPath, "755"); + } + + // Clean up + fs.rmSync(tempWorkspace, { recursive: true, force: true }); + + console.log("✅ PMG binary installed successfully!"); + } catch (error) { + console.error("❌ Installation failed:", error.message); + + // Clean up on failure + try { + if (tempWorkspace && fs.existsSync(tempWorkspace)) { + fs.rmSync(tempWorkspace, { recursive: true, force: true }); + } + } catch (cleanupError) { + console.warn("⚠️ Failed to clean up:", cleanupError.message); + } + + process.exit(1); + } +} + +// Run installation +install(); diff --git a/publish/npm/package.json b/publish/npm/package.json new file mode 100644 index 0000000..daa3e78 --- /dev/null +++ b/publish/npm/package.json @@ -0,0 +1,57 @@ +{ + "name": "@safedep/pmg", + "description": "PMG protects developers from getting compromised by malicious packages", + "main": "bin/pmg.js", + "bin": { + "pmg": "bin/pmg.js" + }, + "scripts": { + "preinstall": "echo \"Installing PMG binary for your platform...\"", + "postinstall": "node install.js" + }, + "keywords": [ + "security", + "package-manager", + "malicious-packages", + "npm", + "cli", + "vulnerability", + "dependency-security", + "safedep" + ], + "author": "SafeDep ", + "license": "Apache-2.0", + "homepage": "https://github.com/safedep/pmg#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/safedep/pmg.git" + }, + "bugs": { + "url": "https://github.com/safedep/pmg/issues" + }, + "engines": { + "node": ">=14" + }, + "os": [ + "darwin", + "linux", + "win32" + ], + "cpu": [ + "x64", + "arm64", + "ia32" + ], + "files": [ + "bin/pmg.js", + "install.js", + "config.js", + "test.js", + "README.md", + ".npmignore" + ], + "publishConfig": { + "access": "public" + }, + "dependencies": {} +}