Ecosystems and parsing
Everything OSPulse says about your risk rests on one question: what does your build actually resolve? A manifest tells you what you asked for. A lockfile tells you what you got. The gap between those two answers is where most of the interesting risk lives.
Supported ecosystems
OSPulse parses nine package ecosystems. Each has its own resolver semantics, its own idea of what a version range means, and its own lockfile conventions — so support is per-ecosystem work, not a generic file parser.
| Ecosystem | Manifests | Lockfiles |
|---|---|---|
| npm | package.json | package-lock.json, npm-shrinkwrap.json, yarn.lock, pnpm-lock.yaml |
| NuGet | *.csproj (PackageReference), Directory.Packages.props, packages.config | packages.lock.json |
| PyPI | requirements.txt, pyproject.toml, Pipfile, setup.py | poetry.lock, Pipfile.lock, uv.lock, fully-pinned requirements.txt |
| Maven | pom.xml, build.gradle, build.gradle.kts | gradle.lockfile |
| Go | go.mod | go.sum |
| Cargo | Cargo.toml | Cargo.lock |
| RubyGems | Gemfile, *.gemspec | Gemfile.lock |
| Composer | composer.json | composer.lock |
| Docker | Dockerfile, compose files | digest-pinned image references |
Where a repository contains several ecosystems — a Python service with a React front end and a Dockerfile, which is entirely ordinary — each is parsed independently and the results are merged into one dependency set for the repository.
Docker is a different shape of dependency
An image reference is a dependency, but the thing it pulls in is a filesystem, not a package.
A tag like node:20-alpine is mutable and can point at different content on different days;
a digest reference pins exact content. Digest-pinned references are to images what lockfiles
are to packages, and for the same reason.
Manifests declare intent, lockfiles record outcome
This is the central idea, and it is worth being precise about.
A manifest is what a human wrote. It expresses intent, usually as a constraint rather than a value:
{
"dependencies": {
"express": "^4.18.0",
"lodash": "~4.17.21"
}
}That file says: give me some version of express that is at least 4.18.0 and below 5, and some 4.17.x of lodash. It does not say which. It says nothing at all about the several hundred packages those two will drag in behind them.
A lockfile is what the resolver decided, written down. It records the exact version of every package in the graph — direct and transitive — usually with an integrity hash and the registry it came from. It is a reproducible, byte-level statement of what your build will install.
The distinction has real consequences:
| From a manifest | From a lockfile | |
|---|---|---|
| Direct dependency names | Yes | Yes |
| Exact direct versions | Only if pinned | Yes |
| Transitive dependencies | No | Yes |
| Exact transitive versions | No | Yes |
| Full dependency paths | No | Yes |
| Integrity hashes / registry origin | No | Usually |
| Reproducible across machines and time | No | Yes |
The row that matters most is the third. Without a lockfile, the majority of your dependency graph is invisible — and, as covered in Dependency drift, the transitive portion is where most supply-chain incidents actually arrive.
Version range semantics
Ranges are the mechanism by which a repository you have not touched can change what it installs. Each ecosystem expresses them differently, and the differences are not cosmetic.
| Notation | Ecosystem | Meaning |
|---|---|---|
^4.18.0 | npm, Cargo | Compatible within the leftmost non-zero component — commonly all of 4.x |
~4.17.21 | npm, RubyGems, Composer | Patch-level movement only, roughly 4.17.x |
>=2.1,<3.0 | PyPI | Explicit bounds |
[4.18,5.0) | NuGet, Maven | Interval notation, inclusive and exclusive bounds |
4.18.* | NuGet | Wildcard on a component |
v4.18.0 in go.mod | Go | A minimum, resolved by minimal version selection |
node:20-alpine | Docker | A mutable tag — no version guarantee at all |
Three consequences follow.
A range is a standing instruction to change. ^4.18.0 does not mean 4.18.0; it means "whatever satisfies this at install time". Nobody has to commit anything for the resolved version to move. That is convenient, and it is also how a compromised release reaches machines within hours of publication.
Range semantics differ enough to change the answer. Go's minimal version selection picks the lowest version satisfying all requirements; npm and Cargo resolvers pick the highest permitted. Applying one ecosystem's mental model to another produces wrong conclusions about what you are running.
Ranges make advisory matching approximate. An advisory says "affected below 4.18.3". If your manifest says ^4.18.0, you might be affected. If your lockfile says 4.18.2, you are — precisely, in a specific repository, at a specific path. Precision here is the difference between a queue people work and a queue people mute.
Direct versus transitive
A direct dependency appears in your manifest. Someone at your organisation chose it, and you can change it by editing a file you own.
A transitive dependency is in your graph because something else needs it. Nobody at your organisation chose it, most likely nobody has heard of it, and you cannot change it directly — you change the thing that brought it in, or you override resolution.
Both execute with the same privileges. Both ship in your artefact. A test-time-only utility four levels deep runs on your build machine, with your build machine's credentials — which is exactly the position event-stream occupied in 2018.
OSPulse marks the distinction throughout, because it determines the remediation:
DirectdependencyOptionalFix by bumping the version in your manifest. Fully within your control, usually a small change.
TransitivedependencyOptionalFix by bumping the intermediate dependency that requires it — if a fixed version exists. If it does not, your options are a resolution override, a vendored patch, or replacing the direct dependency entirely. Costs escalate sharply with depth.
Both views come from the same two endpoints:
curl https://api.ospulse.app/api/v1/repositories/$REPO_ID/dependencies \
-H "Authorization: Bearer $OSPULSE_TOKEN"The dependency graph adds the edges — which package pulled in which — and is what blast-radius analysis walks:
curl "https://api.ospulse.app/api/v1/repositories/$REPO_ID/dependency-graph?maxEdges=500" \
-H "Authorization: Bearer $OSPULSE_TOKEN"maxEdges keeps large graphs workable
Real graphs get big — a mid-sized application easily exceeds a thousand edges. maxEdges
bounds the response so you can render or traverse a sensible subgraph rather than pulling the
whole thing when you only need the paths to one package.
What OSPulse can and cannot resolve without a lockfile
Being direct about the limits is more useful than implying complete coverage.
With a lockfile present, OSPulse determines the exact resolved version of every package in the graph, the full path from each direct dependency to each transitive one, and — where the lockfile records them — integrity hashes and registry origins. Advisory matching is exact. Drift is measured against real resolution, so a graph change shows up as a change rather than hiding inside an unchanged range.
With only a manifest, OSPulse can identify your direct dependencies and their declared ranges, score the health of those packages, flag licence and deprecation issues on them, and tell you when a range permits an affected version. What it cannot do is state the resolved version, enumerate transitive dependencies, or confirm rather than suspect an exposure. Findings are correspondingly wider and less certain.
Partial or stale lockfiles are the awkward middle. A lockfile that has drifted out of sync with its manifest describes a build nobody performs any more. OSPulse parses what is committed — the fix is in your repository, not in the parser.
Vendored and privately-hosted packages resolve as far as their identity is recorded. A package pulled from an internal registry is tracked as a dependency, but public threat intelligence has nothing to say about a package the public has never seen. Enterprise plans can register private feeds to close that gap — see Threat intelligence.
Runtime-assembled dependencies — anything installed by a script at deploy time, downloaded during a build step, or pip install-ed inside a container at runtime — are not in any manifest and cannot be parsed from source. If it does not appear in a committed file, static parsing will not find it.
Commit your lockfiles
The single highest-value change most teams can make to their dependency visibility is committing lockfiles for every ecosystem in every repository. It costs nothing, makes builds reproducible, and takes OSPulse from "these are the packages you asked for" to "this is exactly what you ship, and here is every path into it".
When there is no source to parse
Sometimes the artefact exists but the repository is not available — a vendor component, a container image built elsewhere, a legacy system with no accessible source. SBOM import covers that case: submit a CycloneDX or SPDX document and OSPulse treats its components as a dependency set like any other, subject to the same scoring, drift measurement, and threat cross-referencing.
curl -X POST https://api.ospulse.app/api/v1/sbom/import \
-H "Authorization: Bearer $OSPULSE_TOKEN" \
-H "Content-Type: application/json" \
-d @sbom.jsonThe reverse works too — POST /api/v1/sbom/export produces an SBOM from a parsed repository, which is what you hand to a customer, an auditor, or a regulator. See Generate an SBOM.
An imported SBOM inherits the fidelity of whoever produced it. If it was generated from a lockfile it is as good as parsing the repository yourself; if it was generated from a manifest it carries the same gaps described above. An SBOM is a serialisation of somebody's dependency resolution, not a substitute for having done one.
How parsing fits the scan lifecycle
Parsing is the first stage of every scan, and everything downstream inherits its accuracy.
Discover
The repository is walked for recognised manifests and lockfiles across all nine ecosystems, including nested projects — monorepos with a dozen packages are the normal case, not the exception.
Parse and resolve
Each file is parsed with its ecosystem's semantics. Lockfiles yield exact versions and edges; manifests yield ranges. Where both exist for the same project, the lockfile is authoritative and the manifest supplies intent.
Normalise identity
Packages are normalised to a canonical ecosystem-plus-name identity. This is what allows the
same package to be recognised across repositories — and what makes
GET /api/v1/packages/{packageId}/usage able to answer "where do we use this" in one call.
Enrich and score
Each resolved package is joined to its health snapshot, advisories, licence data, and threat signals. This is where parsing output becomes the risk picture described in Health scoring.
Filtering the package list by ecosystem is useful when you want to reason about one at a time — an npm upgrade programme, or a Python runtime migration:
curl "https://api.ospulse.app/api/v1/packages?ecosystem=Npm&take=50" \
-H "Authorization: Bearer $OSPULSE_TOKEN"