DedSec Application Security ·
The question usually arrives after the fact. A dependency turns out to have shipped code that read environment variables and sent them somewhere, and someone asks why the composition scan on every pull request said nothing. The log shows it ran and matched every package against the advisory feeds.
It was not broken. It answered the question it was built for: is a version with a known vulnerability present? A malicious package is a different question.
Conventional software composition analysis is a lookup. It resolves the dependency tree from manifests and lockfiles and checks each name and version against advisories listing affected ranges. Every step assumes a mistake made in good faith. A malicious package is not a mistake, and it breaks the lookup in three ways.
It may never get a CVE. The OpenSSF malicious-packages project publishes malware reports in the OSV format under its own MAL- prefixed identifiers, and OSV.dev serves them alongside conventional advisories. Some older malicious packages were given CVE IDs, but nothing requires it, and a scanner that consumes only CVE-keyed data does not see the rest.
The report can arrive after the installs. A malicious version does its damage between publication and discovery, and the report can only follow discovery. A lookup run in that window finds nothing, because there is nothing yet to find.
The affected range is often every version. A typosquat or a confusion package has no clean version to upgrade to. The remediation is removal and incident response, not a bump.
So a clean CVE report can be correct and still say nothing about malice. Malware feeds close the gap only for packages someone has already reported.
The intuition that a dependency is harmless until your application imports it is wrong for install-time code.
npm lifecycle scripts. A package can declare preinstall, install and postinstall scripts, which npm runs while installing it. A package with a binding.gyp file and no install or preinstall script gets a default node-gyp rebuild, which is how native modules are compiled. These scripts run with the installing user's environment, filesystem and network access.
Python source distributions. When pip installs from an sdist rather than a wheel, it builds the package, which runs the project's build backend; for a setuptools project that means executing setup.py. pip's documentation says plainly that by default installing involves running arbitrary code from distributions.
That happens on a developer's laptop with cloud credentials and SSH keys in the home directory, and on CI runners with deployment and publish tokens in the environment, before any test runs and often before any scan. A malicious postinstall script does not need to be reachable, imported or called.
The controls exist, and each has a cost.
None of these touch code that runs after installation: on import, or, for Python, from a .pth file the package installs, which runs at every interpreter start. They remove the install-time path, the cheapest one for an attacker.
A typosquat is a package named to be installed by mistake. Detection compares each dependency name against popular package names and asks whether it is suspiciously close:
The false positives are structural. A three-letter package has many legitimate neighbours one edit away, so a plain "within distance one of a popular package" threshold flags legitimate short names constantly, and a team that sees that a few times stops reading the check. A useful signal weighs asymmetry, not just distance:
Registry-side filtering changes what reaches you too: the simplest squats are often rejected at publish time, so what gets through is more likely to be a combosquat or a scope variant, which is where consumer-side checks earn their keep.
Dependency confusion needs no typo. The attacker publishes, on the public registry, a package with the name of one of your internal packages and a high version number. Whether it gets installed depends on how your resolver treats more than one index.
pip's documentation is explicit: packages are looked for on the main index and in any repositories given by --find-links or --extra-index-url, there is no priority between them, and the best match by version number is selected. With an internal index configured as an extra index alongside public PyPI, a public package carrying your internal name and a higher version wins, exactly as documented. The setting often lives in PIP_EXTRA_INDEX_URL on a CI image, where it outlives the people who chose it.
The mitigations follow:
You are the only party who knows which public names should not exist, which is why this is a configuration problem before it is a detection one.
The hardest case is a legitimate package whose maintainer account, publish token or release pipeline is used to publish a malicious version. The name is right, so the typosquat check is satisfied.
Lockfiles help, up to the next update. A package-lock.json records, for each installed package, where it was resolved from and an integrity string, a sha512 or sha1 Subresource Integrity hash of the artifact. npm never lets a name and version combination be reused, even after unpublishing, and Go's checksum database ensures the bits for a given module version do not change later. So on npm a takeover has to publish a new version; on PyPI an attacker can also add a new file, such as a wheel for another platform, to an existing release, which lockfile hashes catch and version pins alone do not. Either way the lockfile protects you exactly until something updates it: an automated bump, a developer running an update, or a new dependency whose range resolves to the new release.
A release-age window is a real trade-off. npm's min-release-age setting builds the tree only from versions available for more than a given number of days, and --before does the same with a fixed date. pip has --uploaded-prior-to, which takes a date or a duration, and works only against indexes that publish upload times. A window gives the ecosystem time to notice a malicious release before you install it.
The cost is in npm's own documentation: when the window stops npm audit fix from installing a fix because it is too new, npm keeps the vulnerable version, warns and exits non-zero. The window delays malicious releases and security fixes alike. npm offers min-release-age-exclude for exemptions; the operational answer is a policy for who may add one, for which advisory, and until when. Without it the window is too strict to keep or quietly bypassed.
Build provenance answers "where did this artifact come from?", not "is this artifact safe?".
npm provenance. A package published with provenance carries an attestation linking it to its source repository and build instructions. It is signed through Sigstore's public servers and recorded in a public transparency log. Publishing with provenance requires a supported cloud CI/CD provider on a cloud-hosted runner; npm's documentation covers GitHub Actions and GitLab CI/CD. npm audit signatures verifies registry signatures and provenance attestations for an installed tree.
PyPI attestations. PEP 740, accepted in July 2024, defines how digital attestations are uploaded to an index and served to clients. PyPI binds each release file to a digest of its contents and to the identity that attested it, such as a GitHub Actions workflow configured as a Trusted Publisher, PyPI's term for publishing with short-lived OpenID Connect tokens instead of a long-lived API token.
What it proves. That this artifact was built by this workflow in this repository, as recorded in a signed, publicly logged attestation. That is strong evidence against a stolen token used to publish from somewhere else.
What it does not prove. npm's documentation says provenance does not guarantee the package has no malicious code. A malicious commit merged into the real repository and built by the real workflow produces valid provenance. So does a compromised workflow. PyPI's security model notes that attestations are not a substitute for limiting who can trigger publishing workflows. PEP 740 also makes no recommendation that attestations be mandatory or verified by installers such as pip.
Absence is not evidence of malice. Provenance is opt-in and needs supported hosted CI, so a package published from a maintainer's own machine cannot have it. Treating "no provenance" as a finding on its own recreates the noisy typosquat check.
The useful signal is a change. A package whose recent versions all carried provenance from one repository and workflow, and whose new version lacks it or names a different repository, has changed how it is released. That is consistent with a stolen publish token, and also with a legitimate change of CI, and either way it deserves a human look before you upgrade.
None of this replaces conventional CVE matching. It covers the mistakes maintainers ship in good faith, and its problem is volume. Two filters belong ahead of severity.
Known exploitation before severity. CVSS describes how bad a vulnerability would be if exploited, not whether anyone is exploiting it. CISA's Known Exploited Vulnerabilities catalog lists vulnerabilities that meet three criteria: an assigned CVE ID, reliable evidence of active exploitation in the wild, and a clear remediation action such as a vendor update. A KEV entry in a package you actually run is a different item from a high CVSS score in one you might not. Note the first criterion: a malicious package without a CVE can never appear in KEV, which is one more reason malware needs its own channel.
Reachability next. Many vulnerable functions in a dependency tree sit in code the application never calls. CVE reachability asks whether the vulnerable code path can be reached from your code at all. The post on reachability covers it, including where it is wrong. Reachability applies to vulnerable library code, not to install scripts, which run whether or not anything is reachable.
A useful order is present, then known-exploited, then reachable, then severity and exposure to untrusted input. Presence comes first because an advisory for a package in a repository nobody builds is the cheapest finding to retire.
AppSecD's software composition analysis runs Trivy and OSV across npm, PyPI, Go, Maven, RubyGems, Cargo, Packagist and NuGet, and adds typosquat detection, malicious-package detection and provenance checking on top of conventional CVE matching. Advisories from 18 feeds are correlated against the versions, manifests and repositories actually present in your estate, with CISA KEV flagged and a blast-radius view per CVE. CVE reachability answers whether the vulnerable code path can be reached at all, and pull requests and commits can be gated by policy, so a violation is stopped while its author still has the context. A gate acts on the scan result; it does not undo install scripts that ran before the scan, which is why the install-step controls above matter. The techniques described in this post are general practice, not a description of how AppSecD's checks are implemented.
Script policy, binary-only installs, index configuration and release-age windows are package-manager configuration, yours to set whatever scanner you run; this post describes them as general practice.
These apply to any composition scanner and package manager.
A composition report that says "no known vulnerabilities" is a precise statement about a lookup. Whether a dependency is safe to install is a separate question, and much of the answer is in how you install it.