mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
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 <bansalsahil315@gmail.com> * Update .github/workflows/publish-npm.yml Co-authored-by: Omkar Phansopkar <omkarphansopkar@gmail.com> Signed-off-by: Sahil Bansal <bansalsahil315@gmail.com> * 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 <bansalsahil315@gmail.com> * 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 <bansalsahil315@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Omkar Phansopkar <omkarphansopkar@gmail.com>
This commit is contained in:
co-authored by
Copilot
Omkar Phansopkar
parent
ca752edf79
commit
74248cd74a
@@ -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
|
||||
@@ -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
|
||||
@@ -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)**
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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 <devops@safedep.io>",
|
||||
"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": {}
|
||||
}
|
||||
Reference in New Issue
Block a user