Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/safe-chain/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"dependencies": {
"abbrev": "3.0.1",
"chalk": "5.4.1",
"js-yaml": "^4.1.0",
"make-fetch-happen": "14.0.3",
"npm-registry-fetch": "18.0.2",
"ora": "8.2.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { matchesCommand } from "../_shared/matchesCommand.js";
import { commandArgumentScanner } from "./dependencyScanner/commandArgumentScanner.js";
import { lockfileScanner } from "./dependencyScanner/lockfileScanner.js";
import { runPnpmCommand } from "./runPnpmCommand.js";

const scanner = commandArgumentScanner();
const commandScanner = commandArgumentScanner();
const lockfileScannerInstance = lockfileScanner();

export function createPnpmPackageManager() {
return {
Expand Down Expand Up @@ -34,13 +36,20 @@ export function createPnpxPackageManager() {

function getDependencyUpdatesForCommand(args, isPnpx) {
if (isPnpx) {
return scanner.scan(args);
return commandScanner.scan(args);
}
if (args.includes("dlx")) {
// dlx is not always the first argument (eg: `pnpm --package=yo --package=generator-webapp dlx yo webapp`)
// so we need to filter it out instead of slicing the array
// documentation: https://pnpm.io/cli/dlx#--package-name
return scanner.scan(args.filter((arg) => arg !== "dlx"));
return commandScanner.scan(args.filter((arg) => arg !== "dlx"));
}
return scanner.scan(args.slice(1));

// Check if we should use lockfile scanner for install commands without explicit packages
if (lockfileScannerInstance.shouldScan(args)) {
return lockfileScannerInstance.scan(args);
}

// Fall back to command argument scanner for explicit package arguments
return commandScanner.scan(args.slice(1));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { generatePnpmLockfile, readPnpmLockfile } from "../runPnpmLockfileCommand.js";
import { parsePnpmLockfile } from "../parsing/parsePnpmLockfile.js";

export function lockfileScanner() {
return {
scan: (args) => scanDependenciesFromLockfile(args),
shouldScan: (args) => shouldScanDependenciesFromLockfile(args),
};
}

function shouldScanDependenciesFromLockfile(args) {
// Only scan for install commands without explicit packages
// This covers cases like "pnpm install", "pnpm i", etc.
const isInstallCommand = args.length === 1 &&
(args[0] === "install" || args[0] === "i");

return isInstallCommand;
}

async function scanDependenciesFromLockfile(args) {
// Generate lockfile to get current dependency state
const lockfileResult = generatePnpmLockfile(args);

if (lockfileResult.status !== 0) {
throw new Error(
`Failed to generate pnpm lockfile with exit code ${lockfileResult.status}: ${lockfileResult.error}`
);
}

// Read the generated lockfile
const readResult = readPnpmLockfile();

if (readResult.status !== 0) {
throw new Error(
`Failed to read pnpm lockfile: ${readResult.error}`
);
}

// Parse the lockfile to extract packages
const packages = parsePnpmLockfile(readResult.content);

return packages;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { lockfileScanner } from "./lockfileScanner.js";

describe("lockfileScanner", () => {
it("should return true for shouldScan when args is install command only", () => {
const scanner = lockfileScanner();

assert.strictEqual(scanner.shouldScan(["install"]), true);
assert.strictEqual(scanner.shouldScan(["i"]), true);
});

it("should return false for shouldScan when args has explicit packages", () => {
const scanner = lockfileScanner();

assert.strictEqual(scanner.shouldScan(["install", "react"]), false);
assert.strictEqual(scanner.shouldScan(["add", "axios"]), false);
assert.strictEqual(scanner.shouldScan(["update"]), false);
});

it("should return false for shouldScan when args is empty", () => {
const scanner = lockfileScanner();

assert.strictEqual(scanner.shouldScan([]), false);
});

it("should detect malicious packages from lockfile when pnpm install is run", async () => {
// This test verifies that the lockfile scanner can detect malicious packages
// when they are present in package.json and pnpm install is run.
// The actual lockfile generation and reading will be tested in integration tests.

const scanner = lockfileScanner();

// Test that the scanner correctly identifies install commands
assert.strictEqual(scanner.shouldScan(["install"]), true);
assert.strictEqual(scanner.shouldScan(["i"]), true);

// Test that it doesn't scan for commands with explicit packages
assert.strictEqual(scanner.shouldScan(["install", "react"]), false);
assert.strictEqual(scanner.shouldScan(["add", "safe-chain-test"]), false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import yaml from "js-yaml";

export function parsePnpmLockfile(lockfileContent) {
try {
const lockfile = yaml.load(lockfileContent);
const packages = [];

// Extract packages from the lockfile
if (lockfile && lockfile.packages) {
for (const [packagePath, packageInfo] of Object.entries(lockfile.packages)) {
// Skip root package
if (packagePath === "") {
continue;
}

// Extract package name and version from the path
const packageDetails = parsePackagePath(packagePath, packageInfo);
if (packageDetails) {
packages.push(packageDetails);
}
}
}

return packages;
} catch (error) {
throw new Error(`Failed to parse pnpm lockfile: ${error.message}`);
}
}

function parsePackagePath(packagePath, packageInfo) {
// Package path format: /package-name/version or /@scope/package-name/version
const pathParts = packagePath.split("/").filter(part => part !== "");

if (pathParts.length < 2) {
return null;
}

let name, version;

if (pathParts[0].startsWith("@")) {
// Scoped package: /@scope/package-name/version
if (pathParts.length < 3) {
return null;
}
name = `@${pathParts[0].substring(1)}/${pathParts[1]}`;
version = pathParts[2];
} else {
// Regular package: /package-name/version
name = pathParts[0];
version = pathParts[1];
}

// Get the resolved version from package info if available
const resolvedVersion = packageInfo.version || version;

return {
name,
version: resolvedVersion,
type: "add" // All packages in lockfile are considered as "add" operations
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { parsePnpmLockfile } from "./parsePnpmLockfile.js";

describe("parsePnpmLockfile", () => {
it("should parse a simple lockfile with regular packages", () => {
const lockfileContent = `
lockfileVersion: '6.0'
packages:
/axios/1.9.0:
version: 1.9.0
resolution: '[email protected]'
/lodash/4.17.21:
version: 4.17.21
resolution: '[email protected]'
`;

const result = parsePnpmLockfile(lockfileContent);

assert.deepEqual(result, [
{ name: "axios", version: "1.9.0", type: "add" },
{ name: "lodash", version: "4.17.21", type: "add" }
]);
});

it("should parse a lockfile with scoped packages", () => {
const lockfileContent = `
lockfileVersion: '6.0'
packages:
/@babel/core/7.23.0:
version: 7.23.0
resolution: '@babel/[email protected]'
/@types/node/20.8.0:
version: 20.8.0
resolution: '@types/[email protected]'
`;

const result = parsePnpmLockfile(lockfileContent);

assert.deepEqual(result, [
{ name: "@babel/core", version: "7.23.0", type: "add" },
{ name: "@types/node", version: "20.8.0", type: "add" }
]);
});

it("should handle empty lockfile", () => {
const lockfileContent = `
lockfileVersion: '6.0'
packages: {}
`;

const result = parsePnpmLockfile(lockfileContent);

assert.deepEqual(result, []);
});

it("should handle lockfile with no packages section", () => {
const lockfileContent = `
lockfileVersion: '6.0'
`;

const result = parsePnpmLockfile(lockfileContent);

assert.deepEqual(result, []);
});

it("should skip root package entry", () => {
const lockfileContent = `
lockfileVersion: '6.0'
packages:
'':
dependencies:
axios: 1.9.0
/axios/1.9.0:
version: 1.9.0
resolution: '[email protected]'
`;

const result = parsePnpmLockfile(lockfileContent);

assert.deepEqual(result, [
{ name: "axios", version: "1.9.0", type: "add" }
]);
});

it("should handle malformed YAML gracefully", () => {
const lockfileContent = `
lockfileVersion: '6.0'
packages:
/axios/1.9.0:
version: 1.9.0
resolution: '[email protected]'
invalid: [yaml: content
`;

assert.throws(() => {
parsePnpmLockfile(lockfileContent);
}, /Failed to parse pnpm lockfile/);
});

it("should parse malicious packages from lockfile for security scanning", () => {
const lockfileContent = `
lockfileVersion: '6.0'
packages:
/safe-chain-test/0.0.1-security:
version: 0.0.1-security
resolution: '[email protected]'
/axios/1.9.0:
version: 1.9.0
resolution: '[email protected]'
/@types/node/20.8.0:
version: 20.8.0
resolution: '@types/[email protected]'
`;

const result = parsePnpmLockfile(lockfileContent);

assert.strictEqual(result.length, 3);

// Verify malicious package is detected
const maliciousPackage = result.find(pkg => pkg.name === "safe-chain-test");
assert.ok(maliciousPackage, "Malicious package should be detected");
assert.strictEqual(maliciousPackage.name, "safe-chain-test");
assert.strictEqual(maliciousPackage.version, "0.0.1-security");
assert.strictEqual(maliciousPackage.type, "add");

// Verify regular packages are also detected
const regularPackage = result.find(pkg => pkg.name === "axios");
assert.ok(regularPackage, "Regular package should be detected");
assert.strictEqual(regularPackage.name, "axios");
assert.strictEqual(regularPackage.version, "1.9.0");
assert.strictEqual(regularPackage.type, "add");

// Verify scoped packages are detected
const scopedPackage = result.find(pkg => pkg.name === "@types/node");
assert.ok(scopedPackage, "Scoped package should be detected");
assert.strictEqual(scopedPackage.name, "@types/node");
assert.strictEqual(scopedPackage.version, "20.8.0");
assert.strictEqual(scopedPackage.type, "add");
});
});
Loading