July 27, 2026
Software Composition Analysis (SCA) in Cybersecurity
Why does each language and package manager need its own solution?
By April Kang
4 min read
If you have ever wondered why a security tool can scan your Python dependencies flawlessly but stumble on your Go modules — or why adding a build system like Bazel suddenly breaks everything — the answer comes down to one uncomfortable truth: there is no shared standard for how software declares its dependencies.
Every language ecosystem invented its own model for how dependencies are declared, resolved, versioned, and locked. Software Composition Analysis (SCA) — the practice of scanning your third-party open-source packages for known vulnerabilities — has to speak each of these dialects natively. There is no universal parser that can cover them all.
Here is why.
What SCA Actually Has to Do
Before we get into the differences, it helps to be precise about the job. To flag a vulnerable dependency correctly, an SCA tool must answer four questions:
-
What dependencies do you use? Read the manifest and lock files.
-
What exact version of each do you use? Resolve the version.
-
Does a known vulnerability affect that version? Match it against a vulnerability database.
-
Does your code actually reach the vulnerable code? Compute reachability to filter out noise.
Every one of these steps depends on getting the version exactly right. And getting the version right is where the ecosystems diverge wildly.
The Five Axes of Divergence
- File Format and Location
Each ecosystem stores its dependency information in different files, in different formats, and in different places.
Python Manifest: requirements.txt or pyproject.toml Lock file: Pipfile.lock or poetry.lock
JavaScript Manifest: package.json Lock file: package-lock.json, yarn.lock, or pnpm-lock.yaml
Go Module definition: go.mod Integrity checksums: go.sum
Java Manifest or build configuration: pom.xml or build.gradle Lock file: varies
Rust Manifest: Cargo.toml Lock file: Cargo.lock
Different syntaxes — TOML, JSON, YAML, XML, and custom formats — mean each ecosystem needs its own parser before you can even begin.
- Version Resolution Algorithm: The Deep Difference
This is the one that catches people off guard. The word "dependency" means the same thing everywhere, but the math of picking versions is completely different.
• npm and Yarn build a nested tree. The same package can exist at many versions simultaneously because each dependency can bring its own copy. Resolution requires walking that tree.
• Go uses Minimal Version Selection (MVS). It selects one version of a module across the build according to the minimum versions required by the module graph.
• Python uses a flat namespace: exactly one version per package in an environment. Incompatible requirements produce a resolution conflict.
• Maven applies dependency-mediation rules such as "nearest definition," in which the version closest to the project in the dependency tree is selected.
• pnpm uses a content-addressed store with a symlinked dependency layout, making its installation structure different again.
Reachability analysis only works if you know the exact resolved version. Apply the wrong algorithm and you get the wrong version, which means matching against the wrong list of vulnerabilities.
- Lock File Present vs. Absent: The Lockfileless Problem
When a lock file is present, such as pnpm-lock.yaml or Cargo.lock, much of the resolved dependency graph is already available. This is the easier case.
When a lock file is absent, such as with a bare requirements.txt or pom.xml, you may have to resolve the graph yourself. That can mean recursively retrieving metadata from a registry and applying the ecosystem's resolution algorithm to reproduce what would actually be installed.
The lockfileless case is dramatically harder, and it is another reason a single generic approach falls short.
- Package Identity and Naming
Vulnerability database lookups depend on the exact package identifier, and its format differs by ecosystem. One industry standard for representing package identities is the Package URL, or PURL.
Examples include:
pkg:npm/foo
pkg:golang/github.com/x/y
pkg:pypi/foo
pkg:maven/group/artifact
Case sensitivity, namespaces, scopes, and group IDs all vary. Get the identity format wrong and the vulnerability lookup may silently miss a match.
- Registry Protocol
When you need to fetch metadata to resolve versions, every ecosystem exposes a different interface. The npm registry, the Go module proxy, PyPI, and Maven Central use different protocols and metadata formats. A resolver has to know how to communicate with each one.
Then Build Systems Make It Worse
Just when you have a parser and resolver for each language, a build system such as Bazel enters the picture and adds another layer.
Bazel does not replace your package manager. It sits above or integrates with it. In a typical Bazel setup:
• Bazel build files identify the dependencies used by a build target.
• Exact external versions may live in MODULE.bazel, repository rules, go.mod, pnpm-lock.yaml, or another ecosystem-specific source.
• Bazel determines which subset of the available dependencies a particular build target actually uses and ships.
This means the real set of shipped dependencies may not exist in any single file. It is computed from the build graph. Two sources can hold different parts of the answer.
For example:
BUILD.bazel identifies the dependency: deps = ["@com_github_gin_gonic_gin"]
go.mod identifies the module version: require github.com/gin-gonic/gin v1.9.1
An SCA tool that reads go.mod naively may report dependencies that no relevant target actually uses, creating false positives. It may also pick up a stale version that never ships.
When Bazel owns the production build, the most authoritative answer often comes from asking Bazel for the configured dependency graph through query tools such as bazel cquery, then mapping the resulting labels to their package identities and versions.
The painful part in a real monorepo is that the same language can be managed in two different ways. Some services may use the native package manager alone, while others are built through Bazel, side by side in the same repository.
The Takeaway
A correct SCA result requires all five of these to be right at once:
Parser + resolution algorithm + lockfile or lockfileless handling + PURL naming + registry API
Each of these differs by ecosystem, and reachability is only trustworthy when every one of them is correct. That is why you cannot write one generic dependency scanner and call it done.
You build per-ecosystem support. Then, when a build system such as Bazel is involved, you add a cross-cutting layer that resolves the real shipped graph instead of trusting a raw manifest by itself.
It is not that any one ecosystem is badly designed. They were each designed independently, for different priorities, and SCA has to meet every one of them exactly where it lives.