Skip to content

Commit aa97861

Browse files
authored
feat: add pnpm virtual store/node_modules support
Add support for detecting dependencies in pnpm-based Node.js projects when scanning container images that did not have a package-lock file Problem: When scanning container images containing pnpm projects (like n8n), dependencies were not being detected in cases where the package-lock was. This is because pnpm stores packages in a .pnpm/ virtual store directory structure: node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>/package.json The existing snyk-resolve-deps library expects packages directly at node_modules/<pkg>/ and doesn't understand pnpm's directory layout. Solution: Added tryBuildDepGraphFromPnpmStore() as a fallback that: 1. Detects when .pnpm/ package.json files are present 2. Parses them directly to extract package names and versions 3. Builds a dependency graph from those packages This runs before snyk-resolve-deps and only activates for pnpm projects.
1 parent 783d79d commit aa97861

File tree

2 files changed

+112
-2
lines changed

2 files changed

+112
-2
lines changed

lib/analyzer/applications/node.ts

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { DepGraph, legacy } from "@snyk/dep-graph";
1+
import { DepGraph, DepGraphBuilder, legacy } from "@snyk/dep-graph";
22
import * as Debug from "debug";
33
import * as path from "path";
44
import * as lockFileParser from "snyk-nodejs-lockfile-parser";
@@ -95,6 +95,18 @@ async function depGraphFromNodeModules(
9595
): Promise<AppDepsScanResultWithoutTarget[]> {
9696
const scanResults: AppDepsScanResultWithoutTarget[] = [];
9797
for (const project of nodeProjects) {
98+
// First, try to build dep graph from pnpm virtual store if present
99+
const pnpmResult = await tryBuildDepGraphFromPnpmStore(
100+
project,
101+
filePathToContent,
102+
fileNamesGroupedByDirectory,
103+
);
104+
if (pnpmResult) {
105+
scanResults.push(pnpmResult);
106+
continue;
107+
}
108+
109+
// Fallback to snyk-resolve-deps for non-pnpm projects
98110
const { tempDir, tempProjectPath, manifestPath } = await persistNodeModules(
99111
project,
100112
filePathToContent,
@@ -159,6 +171,103 @@ async function depGraphFromNodeModules(
159171
return scanResults;
160172
}
161173

174+
/**
175+
* Builds a dependency graph from pnpm's .pnpm directory.
176+
* snyk-resolve-deps doesn't understand pnpm's virtual store structure,
177+
* so we parse the package.json files directly.
178+
*/
179+
async function tryBuildDepGraphFromPnpmStore(
180+
project: string,
181+
filePathToContent: FilePathToContent,
182+
fileNamesGroupedByDirectory: FilesByDirMap,
183+
): Promise<AppDepsScanResultWithoutTarget | null> {
184+
const projectFiles = fileNamesGroupedByDirectory.get(project);
185+
if (!projectFiles) {
186+
return null;
187+
}
188+
189+
// Find all package.json files inside .pnpm directories
190+
const pnpmPackageJsons = Array.from(projectFiles).filter(
191+
(f) => f.includes("/node_modules/.pnpm/") && f.endsWith("/package.json"),
192+
);
193+
if (pnpmPackageJsons.length === 0) {
194+
return null;
195+
}
196+
197+
// Find the root package.json (parent of node_modules/.pnpm)
198+
const pnpmMatch = pnpmPackageJsons[0].match(/^(.+?)\/node_modules\/\.pnpm\//);
199+
if (!pnpmMatch) {
200+
return null;
201+
}
202+
const rootManifestPath = path.posix.join(pnpmMatch[1], "package.json");
203+
const rootManifestContent = filePathToContent[rootManifestPath];
204+
if (!rootManifestContent) {
205+
return null;
206+
}
207+
208+
let rootPkg: { name?: string; version?: string };
209+
try {
210+
rootPkg = JSON.parse(rootManifestContent);
211+
} catch {
212+
return null;
213+
}
214+
if (!rootPkg.name) {
215+
return null;
216+
}
217+
218+
debug(`Building pnpm dep graph for ${rootPkg.name} from .pnpm directory`);
219+
220+
// Parse all packages from .pnpm and add them as direct dependencies
221+
const builder = new DepGraphBuilder(
222+
{ name: "pnpm" },
223+
{ name: rootPkg.name, version: rootPkg.version || "0.0.0" },
224+
);
225+
226+
const seen = new Set<string>();
227+
for (const pkgJsonPath of pnpmPackageJsons) {
228+
const content = filePathToContent[pkgJsonPath];
229+
if (!content) {
230+
continue;
231+
}
232+
233+
try {
234+
const pkg: { name?: string; version?: string } = JSON.parse(content);
235+
if (!pkg.name || !pkg.version) {
236+
continue;
237+
}
238+
239+
const nodeId = `${pkg.name}@${pkg.version}`;
240+
if (seen.has(nodeId)) {
241+
continue;
242+
}
243+
seen.add(nodeId);
244+
245+
builder.addPkgNode({ name: pkg.name, version: pkg.version }, nodeId);
246+
builder.connectDep(builder.rootNodeId, nodeId);
247+
} catch {
248+
// Skip unparseable files
249+
}
250+
}
251+
252+
if (seen.size === 0) {
253+
return null;
254+
}
255+
256+
const depGraph = builder.build();
257+
debug(`Built pnpm dep graph with ${depGraph.getPkgs().length} packages`);
258+
259+
return {
260+
facts: [
261+
{ type: "depGraph", data: depGraph },
262+
{ type: "testedFiles", data: rootManifestPath },
263+
],
264+
identity: {
265+
type: "pnpm",
266+
targetFile: rootManifestPath,
267+
},
268+
};
269+
}
270+
162271
async function depGraphFromManifestFiles(
163272
filePathToContent: FilePathToContent,
164273
manifestFilePairs: ManifestLockPathPair[],

tslint.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"no-bitwise": false,
99
"max-classes-per-file": false,
1010
"no-console": false,
11-
"variable-name": false
11+
"variable-name": false,
12+
"no-unnecessary-initializer": false
1213
}
1314
}

0 commit comments

Comments
 (0)