Metadata-Version: 2.5
Name: unripe
Version: 0.3.0
Summary: Fail the build when a locked dependency is younger than you trust. One check for npm, pnpm, yarn, bun, PyPI, Cargo, Go, Composer, RubyGems, NuGet and Gradle lockfiles.
Project-URL: Homepage, https://github.com/TexWoW/unripe
Project-URL: Issues, https://github.com/TexWoW/unripe/issues
Author: Tex
License-Expression: MIT
License-File: LICENSE
Keywords: cargo,ci,cooldown,dependencies,go,gradle,lockfile,maven,npm,nuget,pypi,security,supply-chain
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Build Tools
Classifier: Topic :: Software Development :: Quality Assurance
Requires-Python: >=3.11
Description-Content-Type: text/markdown

# unripe

[![PyPI](https://img.shields.io/pypi/v/unripe)](https://pypi.org/project/unripe/)
[![CI](https://github.com/TexWoW/unripe/actions/workflows/ci.yml/badge.svg)](https://github.com/TexWoW/unripe/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)

unripe checks your lockfiles and fails if any dependency version was
published too recently. The default is 3 days. It works across npm, pnpm,
yarn, bun, PyPI, Cargo, Go, Composer, RubyGems, NuGet and Gradle, so a repo
with a Python backend and a JavaScript frontend gets one check and one policy
instead of five different settings.

Here it is on Discourse's `Gemfile.lock`, the evening of October 20, 2026:

```
$ unripe check
unripe: checked 339 versions in 1 lockfile, minimum age 3d

unripe: 2 versions published less than 3d ago
  rubygems aws-sdk-core 3.259.0  7h old, published 2026-10-20 19:21 UTC, old enough at 2026-10-23 19:21 UTC
           Gemfile.lock:1014
  rubygems logster 3.0.0         35h old, published 2026-10-19 15:32 UTC, old enough at 2026-10-22 15:32 UTC
           Gemfile.lock:1126

fail: 2 unripe, 0 unknown, 337 ok, 0 excluded
```

Nothing is wrong with either of those gems. That's normal. Most new
versions are fine. The point is that when one isn't, you find out after
everyone else does, not before.

## Why bother

When a popular package gets hijacked, the bad version is usually spotted
and pulled within hours. In August 2026 a worm went through npm and
published malicious versions of
[444 packages](https://cybernews.com/security/npm-supply-chain-attack-shai-hulud-worm/)
in about four hours, stealing CI and cloud credentials from whoever
installed them. In March, two backdoored LiteLLM releases sat on PyPI for
somewhere between
[40 minutes and three hours](https://docs.litellm.ai/blog/security-update-march-2026).
Waiting a few days before taking a new version would have skipped both.

Most package managers have added a setting for this in the last year, and
if yours has one you should turn it on. unripe is for what those settings
don't do. It checks what is already in the lockfile, including whatever a bot
or an old tool put there. It reports on just the versions a pull request
adds. It applies one policy to every ecosystem in the repo. And it covers Go,
Composer, NuGet and Gradle, which don't have a setting yet.

## Install

You need Python 3.11 or newer. unripe has no dependencies, on purpose: a
tool that checks your supply chain shouldn't add to it.

```
uvx unripe check
```

or `pipx install unripe`, or `pip install unripe`.

## Lockfiles it reads

| File | Publish times come from |
|---|---|
| `package-lock.json`, `npm-shrinkwrap.json` | npm registry |
| `pnpm-lock.yaml` | npm registry |
| `yarn.lock` (classic and berry) | npm registry |
| `bun.lock` | npm registry |
| `uv.lock` | the lockfile's own upload times |
| `poetry.lock`, `Pipfile.lock` | PyPI |
| `requirements*.txt`, `constraints*.txt` | PyPI (exact `==` pins only) |
| `Cargo.lock` | crates.io sparse index |
| `go.mod` | Go module proxy and checksum database |
| `composer.lock` | the lockfile's own `time` field, else Packagist |
| `Gemfile.lock` | rubygems.org |
| `packages.lock.json` | nuget.org |
| `gradle.lockfile`, `gradle/dependency-locks/*.lockfile` | Maven Central, Google's Maven repository, the Gradle plugin portal |

Dependencies from git, a local path, a workspace or another registry are
skipped, since there's nothing public to ask about them. The old binary
`bun.lockb` can't be read; bun 1.2 and later write a text `bun.lock`.

NuGet's `packages.lock.json` doesn't record which feed a package came from,
so unripe looks everything up on nuget.org. Packages from a private feed come
back unknown; exclude them. For Gradle, artifacts that live in another Maven
repository (Confluent's, say) can be found by adding it to
`maven-repositories` in the config.

For Go it reads `go.mod`, not `go.sum`. Since Go 1.17, `go.mod` lists every
module in the build, while `go.sum` also remembers versions that aren't used.

## In GitHub Actions

```yaml
name: unripe
on: pull_request

permissions:
  contents: read

jobs:
  unripe:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: TexWoW/unripe@v0.2.0
        with:
          min-age: 3d
```

On a pull request it only looks at versions the PR adds, compared with the
base commit. It fetches that commit if the checkout is shallow, so you don't
need `fetch-depth: 0`. On a push it checks everything. The report goes to the
job summary.

Pin the action to a full commit sha rather than a tag if you can. The action
runs unripe from its own checkout, so the code that runs is the commit you
pinned, and nothing gets downloaded from PyPI.

Inputs:

| Input | Default | |
|---|---|---|
| `min-age` | `3d` | `90m`, `72h`, `3d`, `1w` |
| `since` | PR base | a git ref, or `none` to check everything |
| `paths` | whole repo | files or directories, space or newline separated |
| `exclude` | | patterns, one per line |
| `unknown` | `fail` | set to `warn` to let unknown ages pass |
| `summary` | `true` | write the job summary |
| `sarif` | | also write SARIF to this path |
| `config` | | a config file other than the defaults |

If you want the results as code scanning alerts on the lockfile lines, write
SARIF and upload it (this needs `security-events: write`):

```yaml
      - uses: TexWoW/unripe@v0.2.0
        with:
          sarif: unripe.sarif
      - uses: github/codeql-action/upload-sarif@v4
        if: always()
        with:
          sarif_file: unripe.sarif
```

## As a pre-commit hook

```yaml
repos:
  - repo: https://github.com/TexWoW/unripe
    rev: v0.2.0
    hooks:
      - id: unripe
```

It runs when a lockfile changes. Add `args: [--since, HEAD]` if you only
want it to look at what the commit adds.

## Configuration

Put settings in `.unripe.toml`, or under `[tool.unripe]` in
`pyproject.toml`. Flags on the command line win. Excludes from both are
combined.

```toml
min-age = "3d"
unknown = "fail"
paths = ["web", "api"]
maven-repositories = ["https://packages.confluent.io/maven"]
exclude = [
  "npm:@myorg/*",
  "left-pad@1.3.0",
]
```

An exclude is a package name or a glob. Prefix it with an ecosystem
(`npm:`, `pypi:`, `cargo:`, `go:`, `composer:`, `rubygems:`, `nuget:`,
`maven:`) to limit it to one, and add `@version` to limit it to certain versions, like
`@types/node@20.*`. Gradle packages are named `group:artifact`, like
`maven:com.mycorp:*`. PyPI names are normalised the way PyPI does it, so
`Typing_Extensions` and `typing-extensions` are the same package, and NuGet
names ignore case.

## Command line

```
unripe check [paths...]
  --min-age AGE         default 3d
  --since REF           only versions added since this git ref
  --exclude PATTERN     can be repeated
  --unknown fail|warn   default fail
  --format FORMAT       text, json, sarif or markdown
  --output FILE         write the report here instead of stdout
  --summary FILE        also append a markdown report, e.g. $GITHUB_STEP_SUMMARY
  --sarif FILE          also write a SARIF report
  --config FILE
  --no-cache
  --verbose             list the versions that passed too

unripe explain ECOSYSTEM NAME VERSION
```

`unripe` with no command runs `unripe check`. It exits 0 when everything is
old enough, 1 when something is too new or has an unknown age, and 2 when a
lockfile can't be read, no lockfile was found, or an option is wrong.

`explain` is handy for a single question:

```
$ unripe explain npm left-pad 1.3.0
npm left-pad 1.3.0: published 2018-04-09 01:10 UTC, 8y ago
old enough: the minimum age is 3d
```

## Details worth knowing

If a registry can't tell unripe when a version was published, because it's
down or rate limiting or has never heard of the version, that version counts
as unknown, and unknown fails. A check that quietly passes when the registry
is unreachable is one an attacker can turn off. Use `--unknown warn` if you'd
rather not.

Publish times never change, so answers are cached on disk forever, in
`~/.cache/unripe` (`%LOCALAPPDATA%\unripe\cache` on Windows), or wherever
`UNRIPE_CACHE` points. A second run on the same lockfiles makes no requests
at all. Each version is looked up once even if several lockfiles have it,
and lookups run 16 at a time. For npm, Cargo, Composer and RubyGems one
request covers every version of a package.

NuGet and Maven needed care too. nuget.org reports an unlisted version as
published in 1900, so unripe reads its real time from the NuGet catalog
instead; otherwise a version unlisted the day it came out would look 126
years old. Maven repositories never change a published file, so a POM's
`Last-Modified` header is when the version was published, and it's there
the moment the file is, before the search index catches up.

Mirrors work through `UNRIPE_NPM_REGISTRY` and `UNRIPE_PYPI_URL`, and
`UNRIPE_MAVEN_REPOS` (comma separated) adds Maven repositories the same way
`maven-repositories` does. For Go,
unripe uses the first proxy in `GOPROXY`.

Go needed extra work. The module proxy only reports a version's commit
time, and a commit time is whatever the author says it is, so a tag pushed
today on an old commit (or a commit dated last year on purpose) would look
old. So unripe also asks Go's checksum database, sum.golang.org, which gives
every module version a number in the order it first saw them and can't be
rewritten. Placing that number against the timeline of the Go module index
says roughly when the version first appeared. If the commit claims to be
older than that, unripe goes by first appearance. For recent versions the
estimate is within minutes; for old ones, where it can't narrow things down,
it uses the youngest age it can't rule out. `GOSUMDB`, `GONOSUMDB` and
`GOPRIVATE` are honoured, and private modules fall back to the commit time.
[docs/go.md](docs/go.md) explains how it works.

`uv.lock` and `composer.lock` carry publish times themselves and unripe
trusts them. Someone who can edit your lockfile can change those times, but
they could also point it at any package they like, so that isn't something
unripe tries to defend against.

## Next to the built-in settings

As of October 2026:

| | Setting |
|---|---|
| npm 11.10+ | `min-release-age` in `.npmrc`, in days |
| pnpm 10.16+ | `minimumReleaseAge` in `pnpm-workspace.yaml`, in minutes (on by default since pnpm 11) |
| yarn 4.10+ | `npmMinimalAgeGate` in `.yarnrc.yml` |
| bun 1.3+ | `minimumReleaseAge` in `bunfig.toml`, in seconds |
| uv | `exclude-newer`, a date or a duration like `7d` |
| pip 26.1+ | `--uploaded-prior-to P3D` |
| Bundler 4.0.13+ | `bundle config set cooldown 3`, in days |
| Hex 2.5.0+ | `mix hex.config cooldown 3d` |
| Cargo | unstable `-Zmin-publish-age` now, expected stable in Rust 1.100 |
| Composer | proposed for 2.11 |
| Go, NuGet, Maven, Gradle | nothing yet |

These stop a new version at install time, which unripe can't do, so use
them where you can. unripe is the check in CI afterwards: one policy for the
whole repo, run against what actually got locked. One more difference: some
of the built-in settings let a version through when they can't find its
publish date. unripe fails instead.

[cooldowns.dev](https://cooldowns.dev) keeps a longer, up to date list of
these settings, with examples.

## Using it from Python

```python
import unripe

for finding in unripe.scan(["."], min_age="3d", exclude=["npm:@myorg/*"]):
    if finding.state != "ok":
        print(finding.package, finding.state, finding.reason)
```

Each finding's state is `ok`, `unripe`, `unknown` or `excluded`. `scan()`
raises `unripe.check.Problem` if a lockfile can't be read or the `since` ref
doesn't exist.

## Questions

**Our own packages are always new.** Exclude them, e.g. `npm:@myorg/*`.

**We use a private registry.** unripe can't see into it, so those packages
come back unknown. Exclude them, or use `--unknown warn`. For private Go
modules, point `GOPROXY` at your own proxy.

**Does this replace `npm audit` or similar?** No. Audit tools know about
reported vulnerabilities. unripe doesn't know anything about any package. It
just won't trust a version until it has been around for a while. They catch
different things, so run both.

**Is it slow?** The first run on a big repo takes a while. Twenty real
lockfiles with about 7,800 versions between them took 82 seconds cold and
1 second after that.

## Contributing

Bug reports and pull requests are welcome. [CONTRIBUTING.md](CONTRIBUTING.md)
explains how to run the tests and how to add a new ecosystem, which is the
most useful thing anyone could add right now.

## License

MIT
