Safeguard
Open Source Security

How Snyk parses npm, yarn, and pnpm lockfiles differently...

How Snyk resolves exact package versions from npm, Yarn, and pnpm lockfiles — and why each format's structure demands its own parsing logic.

Vikram Iyer
Security Researcher
7 min read

When you run a dependency scan, the tool doesn't actually know what's installed on disk — it knows what's declared. Two different lockfiles pointing at "lodash ^4.17.20" can resolve to different exact builds depending on when they were generated, which registry mirror answered, and which package manager wrote the file. Getting from a semver range to a single resolved version, with a correct parent-child chain back to your direct dependencies, is the whole game in software composition analysis. Get it wrong and you either miss a vulnerable transitive package buried four levels deep, or you flag one that was never actually installed.

Snyk's open source scanning has to solve this three times over, because npm, Yarn, and pnpm each encode "what got installed" in a structurally different way. This piece walks through those differences mechanically, based on Snyk's publicly documented behavior and its open-sourced Node.js lockfile parser, and why each format demands its own resolution logic.

Why can't one parser handle all three lockfile formats?

Because npm, Yarn, and pnpm don't agree on what a lockfile is for. npm's package-lock.json is a snapshot of a resolved dependency tree (or, from npm 7 onward, a flat install manifest). Yarn's yarn.lock is closer to a pinning cache — a flat list of resolved version blocks with no explicit parent-child edges recorded. pnpm's pnpm-lock.yaml sits in between: it's YAML, and it explicitly lists each package's own resolved dependencies inline, because pnpm's content-addressable store model needs that information to build its symlink tree at install time. A parser that assumes "lockfile = tree" breaks on Yarn; one that assumes "lockfile = flat list" breaks on npm v1. Snyk maintains separate parsing paths per ecosystem for exactly this reason — its Node.js lockfile parser (open-sourced on GitHub as @snyk/nodejs-lockfile-parser) branches on the detected lockfile format before it does anything else.

How does Snyk read npm's package-lock.json across its three schema versions?

It has to check the lockfileVersion field first, because npm has shipped three incompatible on-disk shapes since 2017. Version 1 (npm 5 and 6) stores a recursive dependencies object — each package's own conflicting sub-dependencies are nested inside it as a duplicate entry, so reconstructing the real tree means walking that nesting recursively and tracking which level a duplicate was hoisted to. Version 2 (npm 7 and 8) is a transition format: it keeps the old nested dependencies block for backward compatibility with older tooling, but adds a new flat packages object keyed by literal node_modules path, e.g. node_modules/lodash. Version 3 (npm 9+) drops the nested block entirely and ships only the flat packages map. Snyk's parser reads the flat map when it's present, because it's authoritative and cheaper to traverse, and falls back to the recursive walk only for v1 files where no flat map exists. The path-based keys in v2/v3 (node_modules/foo/node_modules/bar) are also what let the parser infer nesting depth and hoisting without re-running npm's own resolution algorithm.

How does Snyk resolve exact versions out of Yarn's flat lockfile?

It has to re-derive the dependency graph, because yarn.lock never stored one in the first place. A classic (Yarn 1) lockfile groups all the semver ranges that resolved to the same install under one merged block header — you'll see something like "lodash@^4.17.15", "lodash@^4.17.21": followed by a single version, resolved tarball URL, and integrity hash. That block tells you the exact version but not who required it. To answer "which package pulled in this version," Snyk's parser cross-references each block's own listed dependencies ranges against every other block in the file, effectively re-solving the semver matching that Yarn did at install time, then stitches the result into a graph rooted at the ranges declared in package.json. This is meaningfully more computation than the npm v2/v3 case, where the edges are already spelled out by path.

What changes when the lockfile comes from Yarn Berry instead of Yarn Classic?

The block identifiers and metadata fields change shape, so the parser can't reuse its Classic logic as-is. Yarn 2 and later (commonly called Berry, in wide use since 2020) rewrote the lockfile format: entries are now keyed by explicit resolution strings like lodash@npm:4.17.21 rather than bare semver-range lists, and the field previously called integrity becomes checksum. Berry also introduced protocol prefixes that a parser has to branch on individually — npm: for registry packages, workspace: for monorepo-local packages that never hit the registry at all, and patch: for packages modified by Yarn's built-in patch feature. A version-resolution routine that only understands bare package names and semver ranges will misread or skip these, which is why Snyk's tooling documents explicit support for Yarn Berry as a distinct case from Yarn Classic rather than treating yarn.lock as one format with a version bump.

How is pnpm's lockfile structurally different, and does that make parsing easier or harder?

It's structurally the most explicit of the three, which simplifies graph reconstruction but adds a different wrinkle: peer dependency variants. pnpm-lock.yaml lists a top-level dependencies/devDependencies map of declared ranges, plus a packages section where each entry is keyed by something like /react-dom@18.2.0 and — critically — includes that specific package's own fully resolved dependencies inline. Because the edges are written directly into the file, a parser doesn't need to re-run semver matching the way it does for Yarn Classic; it can read the graph almost directly off the YAML. The wrinkle is that pnpm creates a distinct store entry per unique peer-dependency combination, so the same package version can appear multiple times with a peer hash suffix, e.g. /react-dom@18.2.0(react@18.2.0). A parser that strips that suffix and de-duplicates naively will collapse two functionally different installed builds into one node and silently lose information about which peer context a vulnerability applies to. pnpm has also revised its own lockfile version number more than once (5.x formats, then 6.0, then 9.0 with npm-workspaces-style catalog support), each shifting minor details of key naming, so a parser has to check that version field the same way it does for npm.

Does any of this actually change what shows up as a vulnerability finding?

Yes — mismatched resolution logic is exactly how a scanner reports a phantom finding or misses a real one. If a tool treats every semver-satisfying entry in a Yarn Classic lockfile as a separate install rather than merging blocks that resolved identically, it can double-count exposure. If it fails to strip pnpm's peer-hash suffixes consistently, it can either merge two genuinely different builds or fragment one real dependency into duplicates, throwing off both the vulnerability count and the remediation path shown to a developer. And if a parser doesn't check lockfileVersion before choosing an npm parsing strategy, it can silently fall back to the wrong schema assumptions on a v3 file and miss transitive dependencies entirely, since v3 has no nested block to fall back to. None of these are exotic edge cases — they're the default lockfile shape for any project on npm 9+, Yarn Berry, or a recent pnpm version, which by mid-2026 describes most active JavaScript codebases.

How Safeguard Helps

Lockfile parsing correctness is foundational plumbing, not a differentiator you should have to think about — but it's worth understanding because it explains why two scanners can point at the same repository and disagree on what's actually installed. Safeguard's software supply chain platform is built around the same principle these lockfile formats point to: the manifest tells you intent, but only the fully resolved, exact-version dependency graph tells you real exposure. Safeguard ingests npm, Yarn (Classic and Berry), and pnpm lockfiles with version-aware parsing that tracks schema changes like npm's lockfileVersion transitions and pnpm's peer-hash package keys, so that a reported vulnerability maps to an artifact that's actually reachable in your build — not a range that merely could have resolved to it. Combined with build provenance and SBOM generation across your CI pipeline, that gives security and engineering teams a dependency graph they can trust down to the exact resolved version, not just the declared one.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.