unripe 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unripe-0.1.0/.gitattributes +2 -0
- unripe-0.1.0/.gitignore +8 -0
- unripe-0.1.0/.pre-commit-hooks.yaml +9 -0
- unripe-0.1.0/LICENSE +21 -0
- unripe-0.1.0/PKG-INFO +303 -0
- unripe-0.1.0/README.md +278 -0
- unripe-0.1.0/action.yml +69 -0
- unripe-0.1.0/pyproject.toml +50 -0
- unripe-0.1.0/scripts/action.sh +63 -0
- unripe-0.1.0/src/unripe/__init__.py +42 -0
- unripe-0.1.0/src/unripe/__main__.py +5 -0
- unripe-0.1.0/src/unripe/check.py +216 -0
- unripe-0.1.0/src/unripe/cli.py +148 -0
- unripe-0.1.0/src/unripe/lockfiles/__init__.py +85 -0
- unripe-0.1.0/src/unripe/lockfiles/javascript.py +249 -0
- unripe-0.1.0/src/unripe/lockfiles/others.py +126 -0
- unripe-0.1.0/src/unripe/lockfiles/python.py +94 -0
- unripe-0.1.0/src/unripe/model.py +62 -0
- unripe-0.1.0/src/unripe/policy.py +154 -0
- unripe-0.1.0/src/unripe/registries.py +310 -0
- unripe-0.1.0/src/unripe/report.py +310 -0
- unripe-0.1.0/tests/conftest.py +137 -0
- unripe-0.1.0/tests/test_check.py +247 -0
- unripe-0.1.0/tests/test_cli.py +138 -0
- unripe-0.1.0/tests/test_lockfiles.py +216 -0
- unripe-0.1.0/tests/test_policy.py +125 -0
- unripe-0.1.0/tests/test_registries.py +238 -0
- unripe-0.1.0/tests/test_report.py +118 -0
- unripe-0.1.0/tests/testdata/bun/bun.lock +33 -0
- unripe-0.1.0/tests/testdata/bun-binary/bun.lockb +0 -0
- unripe-0.1.0/tests/testdata/cargo/Cargo.lock +43 -0
- unripe-0.1.0/tests/testdata/composer/composer.lock +77 -0
- unripe-0.1.0/tests/testdata/go/go.mod +23 -0
- unripe-0.1.0/tests/testdata/live/Cargo.lock +23 -0
- unripe-0.1.0/tests/testdata/live/go.mod +10 -0
- unripe-0.1.0/tests/testdata/live/package-lock.json +110 -0
- unripe-0.1.0/tests/testdata/live/uv.lock +36 -0
- unripe-0.1.0/tests/testdata/npm-v1/package-lock.json +52 -0
- unripe-0.1.0/tests/testdata/npm-v3/package-lock.json +133 -0
- unripe-0.1.0/tests/testdata/pipenv/Pipfile.lock +56 -0
- unripe-0.1.0/tests/testdata/pnpm-v5/pnpm-lock.yaml +42 -0
- unripe-0.1.0/tests/testdata/pnpm-v6/pnpm-lock.yaml +67 -0
- unripe-0.1.0/tests/testdata/pnpm-v9/pnpm-lock.yaml +103 -0
- unripe-0.1.0/tests/testdata/poetry/poetry.lock +73 -0
- unripe-0.1.0/tests/testdata/requirements/requirements.txt +18 -0
- unripe-0.1.0/tests/testdata/ruby/Gemfile.lock +36 -0
- unripe-0.1.0/tests/testdata/uv/uv.lock +57 -0
- unripe-0.1.0/tests/testdata/yarn-berry/yarn.lock +57 -0
- unripe-0.1.0/tests/testdata/yarn-classic/yarn.lock +36 -0
unripe-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
- id: unripe
|
|
2
|
+
name: unripe
|
|
3
|
+
description: Fail when a locked dependency version was published more recently than you trust
|
|
4
|
+
entry: unripe check
|
|
5
|
+
language: python
|
|
6
|
+
files: (^|/)(package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|uv\.lock|poetry\.lock|Pipfile\.lock|Cargo\.lock|go\.mod|composer\.lock|Gemfile\.lock|(requirements|constraints)[\w.-]*\.txt)$
|
|
7
|
+
pass_filenames: true
|
|
8
|
+
require_serial: true
|
|
9
|
+
minimum_pre_commit_version: "3.0.0"
|
unripe-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tex
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
unripe-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: unripe
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fail the build when a locked dependency is younger than you trust. One check for npm, pnpm, yarn, bun, PyPI, Cargo, Go, Composer and RubyGems lockfiles.
|
|
5
|
+
Project-URL: Homepage, https://github.com/TexWoW/unripe
|
|
6
|
+
Project-URL: Issues, https://github.com/TexWoW/unripe/issues
|
|
7
|
+
Author: Tex
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: cargo,ci,cooldown,dependencies,go,lockfile,npm,pypi,security,supply-chain
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Security
|
|
21
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
22
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# unripe
|
|
27
|
+
|
|
28
|
+
unripe checks your lockfiles and fails if any dependency version was
|
|
29
|
+
published too recently. The default is 3 days. It works across npm, pnpm,
|
|
30
|
+
yarn, bun, PyPI, Cargo, Go, Composer and RubyGems, so a repo with a Python
|
|
31
|
+
backend and a JavaScript frontend gets one check and one policy instead of
|
|
32
|
+
five different settings.
|
|
33
|
+
|
|
34
|
+
Here it is on Discourse's `Gemfile.lock`, the evening of October 20, 2026:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
$ unripe check
|
|
38
|
+
unripe: checked 339 versions in 1 lockfile, minimum age 3d
|
|
39
|
+
|
|
40
|
+
unripe: 2 versions published less than 3d ago
|
|
41
|
+
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
|
|
42
|
+
Gemfile.lock:1014
|
|
43
|
+
rubygems logster 3.0.0 35h old, published 2026-10-19 15:32 UTC, old enough at 2026-10-22 15:32 UTC
|
|
44
|
+
Gemfile.lock:1126
|
|
45
|
+
|
|
46
|
+
fail: 2 unripe, 0 unknown, 337 ok, 0 excluded
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Nothing is wrong with either of those gems. That's normal. Most new
|
|
50
|
+
versions are fine. The point is that when one isn't, you find out after
|
|
51
|
+
everyone else does, not before.
|
|
52
|
+
|
|
53
|
+
## Why bother
|
|
54
|
+
|
|
55
|
+
When a popular package gets hijacked, the bad version is usually spotted
|
|
56
|
+
and pulled within hours. In August 2026 a worm went through npm and
|
|
57
|
+
published malicious versions of
|
|
58
|
+
[444 packages](https://cybernews.com/security/npm-supply-chain-attack-shai-hulud-worm/)
|
|
59
|
+
in about four hours, stealing CI and cloud credentials from whoever
|
|
60
|
+
installed them. In March, two backdoored LiteLLM releases sat on PyPI for
|
|
61
|
+
somewhere between
|
|
62
|
+
[40 minutes and three hours](https://docs.litellm.ai/blog/security-update-march-2026).
|
|
63
|
+
Waiting a few days before taking a new version would have skipped both.
|
|
64
|
+
|
|
65
|
+
npm, pnpm, yarn, bun and uv have each added a setting for this in the last
|
|
66
|
+
year or so, and if you use one of them you should turn it on. unripe is for
|
|
67
|
+
what those settings don't do. It checks what is already in the lockfile,
|
|
68
|
+
including whatever a bot or an old tool put there. It reports on just the
|
|
69
|
+
versions a pull request adds. And it covers Go, Composer and RubyGems, which
|
|
70
|
+
have no setting of their own.
|
|
71
|
+
|
|
72
|
+
## Install
|
|
73
|
+
|
|
74
|
+
You need Python 3.11 or newer. unripe has no dependencies, on purpose: a
|
|
75
|
+
tool that checks your supply chain shouldn't add to it.
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
uvx unripe check
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
or `pipx install unripe`, or `pip install unripe`.
|
|
82
|
+
|
|
83
|
+
## Lockfiles it reads
|
|
84
|
+
|
|
85
|
+
| File | Publish times come from |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `package-lock.json`, `npm-shrinkwrap.json` | npm registry |
|
|
88
|
+
| `pnpm-lock.yaml` | npm registry |
|
|
89
|
+
| `yarn.lock` (classic and berry) | npm registry |
|
|
90
|
+
| `bun.lock` | npm registry |
|
|
91
|
+
| `uv.lock` | the lockfile's own upload times |
|
|
92
|
+
| `poetry.lock`, `Pipfile.lock` | PyPI |
|
|
93
|
+
| `requirements*.txt`, `constraints*.txt` | PyPI (exact `==` pins only) |
|
|
94
|
+
| `Cargo.lock` | crates.io sparse index |
|
|
95
|
+
| `go.mod` | Go module proxy, honouring `GOPROXY` |
|
|
96
|
+
| `composer.lock` | the lockfile's own `time` field, else Packagist |
|
|
97
|
+
| `Gemfile.lock` | rubygems.org |
|
|
98
|
+
|
|
99
|
+
Dependencies from git, a local path, a workspace or another registry are
|
|
100
|
+
skipped, since there's nothing public to ask about them. The old binary
|
|
101
|
+
`bun.lockb` can't be read; bun 1.2 and later write a text `bun.lock`.
|
|
102
|
+
|
|
103
|
+
For Go it reads `go.mod`, not `go.sum`. Since Go 1.17, `go.mod` lists every
|
|
104
|
+
module in the build, while `go.sum` also remembers versions that aren't used.
|
|
105
|
+
|
|
106
|
+
## In GitHub Actions
|
|
107
|
+
|
|
108
|
+
```yaml
|
|
109
|
+
name: unripe
|
|
110
|
+
on: pull_request
|
|
111
|
+
|
|
112
|
+
permissions:
|
|
113
|
+
contents: read
|
|
114
|
+
|
|
115
|
+
jobs:
|
|
116
|
+
unripe:
|
|
117
|
+
runs-on: ubuntu-latest
|
|
118
|
+
steps:
|
|
119
|
+
- uses: actions/checkout@v7
|
|
120
|
+
- uses: TexWoW/unripe@v0.1.0
|
|
121
|
+
with:
|
|
122
|
+
min-age: 3d
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
On a pull request it only looks at versions the PR adds, compared with the
|
|
126
|
+
base commit. It fetches that commit if the checkout is shallow, so you don't
|
|
127
|
+
need `fetch-depth: 0`. On a push it checks everything. The report goes to the
|
|
128
|
+
job summary.
|
|
129
|
+
|
|
130
|
+
Pin the action to a full commit sha rather than a tag if you can. The action
|
|
131
|
+
runs unripe from its own checkout, so the code that runs is the commit you
|
|
132
|
+
pinned, and nothing gets downloaded from PyPI.
|
|
133
|
+
|
|
134
|
+
Inputs:
|
|
135
|
+
|
|
136
|
+
| Input | Default | |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| `min-age` | `3d` | `90m`, `72h`, `3d`, `1w` |
|
|
139
|
+
| `since` | PR base | a git ref, or `none` to check everything |
|
|
140
|
+
| `paths` | whole repo | files or directories, space or newline separated |
|
|
141
|
+
| `exclude` | | patterns, one per line |
|
|
142
|
+
| `unknown` | `fail` | set to `warn` to let unknown ages pass |
|
|
143
|
+
| `summary` | `true` | write the job summary |
|
|
144
|
+
| `sarif` | | also write SARIF to this path |
|
|
145
|
+
| `config` | | a config file other than the defaults |
|
|
146
|
+
|
|
147
|
+
If you want the results as code scanning alerts on the lockfile lines, write
|
|
148
|
+
SARIF and upload it (this needs `security-events: write`):
|
|
149
|
+
|
|
150
|
+
```yaml
|
|
151
|
+
- uses: TexWoW/unripe@v0.1.0
|
|
152
|
+
with:
|
|
153
|
+
sarif: unripe.sarif
|
|
154
|
+
- uses: github/codeql-action/upload-sarif@v4
|
|
155
|
+
if: always()
|
|
156
|
+
with:
|
|
157
|
+
sarif_file: unripe.sarif
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## As a pre-commit hook
|
|
161
|
+
|
|
162
|
+
```yaml
|
|
163
|
+
repos:
|
|
164
|
+
- repo: https://github.com/TexWoW/unripe
|
|
165
|
+
rev: v0.1.0
|
|
166
|
+
hooks:
|
|
167
|
+
- id: unripe
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
It runs when a lockfile changes. Add `args: [--since, HEAD]` if you only
|
|
171
|
+
want it to look at what the commit adds.
|
|
172
|
+
|
|
173
|
+
## Configuration
|
|
174
|
+
|
|
175
|
+
Put settings in `.unripe.toml`, or under `[tool.unripe]` in
|
|
176
|
+
`pyproject.toml`. Flags on the command line win. Excludes from both are
|
|
177
|
+
combined.
|
|
178
|
+
|
|
179
|
+
```toml
|
|
180
|
+
min-age = "3d"
|
|
181
|
+
unknown = "fail"
|
|
182
|
+
paths = ["web", "api"]
|
|
183
|
+
exclude = [
|
|
184
|
+
"npm:@myorg/*",
|
|
185
|
+
"left-pad@1.3.0",
|
|
186
|
+
]
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
An exclude is a package name or a glob. Prefix it with an ecosystem
|
|
190
|
+
(`npm:`, `pypi:`, `cargo:`, `go:`, `composer:`, `rubygems:`) to limit it to
|
|
191
|
+
one, and add `@version` to limit it to certain versions, like
|
|
192
|
+
`@types/node@20.*`. PyPI names are normalised the way PyPI does it, so
|
|
193
|
+
`Typing_Extensions` and `typing-extensions` are the same package.
|
|
194
|
+
|
|
195
|
+
## Command line
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
unripe check [paths...]
|
|
199
|
+
--min-age AGE default 3d
|
|
200
|
+
--since REF only versions added since this git ref
|
|
201
|
+
--exclude PATTERN can be repeated
|
|
202
|
+
--unknown fail|warn default fail
|
|
203
|
+
--format FORMAT text, json, sarif or markdown
|
|
204
|
+
--output FILE write the report here instead of stdout
|
|
205
|
+
--summary FILE also append a markdown report, e.g. $GITHUB_STEP_SUMMARY
|
|
206
|
+
--sarif FILE also write a SARIF report
|
|
207
|
+
--config FILE
|
|
208
|
+
--no-cache
|
|
209
|
+
--verbose list the versions that passed too
|
|
210
|
+
|
|
211
|
+
unripe explain ECOSYSTEM NAME VERSION
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`unripe` with no command runs `unripe check`. It exits 0 when everything is
|
|
215
|
+
old enough, 1 when something is too new or has an unknown age, and 2 when a
|
|
216
|
+
lockfile can't be read, no lockfile was found, or an option is wrong.
|
|
217
|
+
|
|
218
|
+
`explain` is handy for a single question:
|
|
219
|
+
|
|
220
|
+
```
|
|
221
|
+
$ unripe explain npm left-pad 1.3.0
|
|
222
|
+
npm left-pad 1.3.0: published 2018-04-09 01:10 UTC, 8y ago
|
|
223
|
+
old enough: the minimum age is 3d
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Details worth knowing
|
|
227
|
+
|
|
228
|
+
If a registry can't tell unripe when a version was published, because it's
|
|
229
|
+
down or rate limiting or has never heard of the version, that version counts
|
|
230
|
+
as unknown, and unknown fails. A check that quietly passes when the registry
|
|
231
|
+
is unreachable is one an attacker can turn off. Use `--unknown warn` if you'd
|
|
232
|
+
rather not.
|
|
233
|
+
|
|
234
|
+
Publish times never change, so answers are cached on disk forever, in
|
|
235
|
+
`~/.cache/unripe` (`%LOCALAPPDATA%\unripe\cache` on Windows), or wherever
|
|
236
|
+
`UNRIPE_CACHE` points. A second run on the same lockfiles makes no requests
|
|
237
|
+
at all. Each version is looked up once even if several lockfiles have it,
|
|
238
|
+
and lookups run 16 at a time. For npm, Cargo, Composer and RubyGems one
|
|
239
|
+
request covers every version of a package.
|
|
240
|
+
|
|
241
|
+
Mirrors work through `UNRIPE_NPM_REGISTRY` and `UNRIPE_PYPI_URL`. For Go,
|
|
242
|
+
unripe uses the first proxy in `GOPROXY`.
|
|
243
|
+
|
|
244
|
+
The Go check is weaker than the rest. The module proxy reports a version's
|
|
245
|
+
commit time, not when it was first published, so a tag pushed today on an
|
|
246
|
+
older commit looks old, and git lets anyone set any commit time. I haven't
|
|
247
|
+
found a public record of when the proxy first saw a version that could be
|
|
248
|
+
used instead.
|
|
249
|
+
|
|
250
|
+
`uv.lock` and `composer.lock` carry publish times themselves and unripe
|
|
251
|
+
trusts them. Someone who can edit your lockfile can change those times, but
|
|
252
|
+
they could also point it at any package they like, so that isn't something
|
|
253
|
+
unripe tries to defend against.
|
|
254
|
+
|
|
255
|
+
## Next to the built-in settings
|
|
256
|
+
|
|
257
|
+
| | Setting |
|
|
258
|
+
|---|---|
|
|
259
|
+
| npm 11.10+ | `min-release-age` in `.npmrc`, in days |
|
|
260
|
+
| pnpm 10.16+ | `minimumReleaseAge` in `pnpm-workspace.yaml`, in minutes |
|
|
261
|
+
| yarn 4.10+ | `npmMinimalAgeGate` in `.yarnrc.yml` |
|
|
262
|
+
| bun 1.3+ | `minimumReleaseAge` in `bunfig.toml`, in seconds |
|
|
263
|
+
| uv | `exclude-newer`, a date or a duration like `7d` |
|
|
264
|
+
| Cargo, Go, Composer, Bundler | nothing built in as of October 2026 |
|
|
265
|
+
|
|
266
|
+
These stop a new version at install time, which unripe can't do, so use
|
|
267
|
+
them where you can. unripe is the check in CI afterwards: one policy for the
|
|
268
|
+
whole repo, run against what actually got locked.
|
|
269
|
+
|
|
270
|
+
## Using it from Python
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
import unripe
|
|
274
|
+
|
|
275
|
+
for finding in unripe.scan(["."], min_age="3d", exclude=["npm:@myorg/*"]):
|
|
276
|
+
if finding.state != "ok":
|
|
277
|
+
print(finding.package, finding.state, finding.reason)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Each finding's state is `ok`, `unripe`, `unknown` or `excluded`. `scan()`
|
|
281
|
+
raises `unripe.check.Problem` if a lockfile can't be read or the `since` ref
|
|
282
|
+
doesn't exist.
|
|
283
|
+
|
|
284
|
+
## Questions
|
|
285
|
+
|
|
286
|
+
**Our own packages are always new.** Exclude them, e.g. `npm:@myorg/*`.
|
|
287
|
+
|
|
288
|
+
**We use a private registry.** unripe can't see into it, so those packages
|
|
289
|
+
come back unknown. Exclude them, or use `--unknown warn`. For private Go
|
|
290
|
+
modules, point `GOPROXY` at your own proxy.
|
|
291
|
+
|
|
292
|
+
**Does this replace `npm audit` or similar?** No. Audit tools know about
|
|
293
|
+
reported vulnerabilities. unripe doesn't know anything about any package. It
|
|
294
|
+
just won't trust a version until it has been around for a while. They catch
|
|
295
|
+
different things, so run both.
|
|
296
|
+
|
|
297
|
+
**Is it slow?** The first run on a big repo takes a while. Twenty real
|
|
298
|
+
lockfiles with about 7,800 versions between them took 82 seconds cold and
|
|
299
|
+
1 second after that.
|
|
300
|
+
|
|
301
|
+
## License
|
|
302
|
+
|
|
303
|
+
MIT
|
unripe-0.1.0/README.md
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# unripe
|
|
2
|
+
|
|
3
|
+
unripe checks your lockfiles and fails if any dependency version was
|
|
4
|
+
published too recently. The default is 3 days. It works across npm, pnpm,
|
|
5
|
+
yarn, bun, PyPI, Cargo, Go, Composer and RubyGems, so a repo with a Python
|
|
6
|
+
backend and a JavaScript frontend gets one check and one policy instead of
|
|
7
|
+
five different settings.
|
|
8
|
+
|
|
9
|
+
Here it is on Discourse's `Gemfile.lock`, the evening of October 20, 2026:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
$ unripe check
|
|
13
|
+
unripe: checked 339 versions in 1 lockfile, minimum age 3d
|
|
14
|
+
|
|
15
|
+
unripe: 2 versions published less than 3d ago
|
|
16
|
+
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
|
|
17
|
+
Gemfile.lock:1014
|
|
18
|
+
rubygems logster 3.0.0 35h old, published 2026-10-19 15:32 UTC, old enough at 2026-10-22 15:32 UTC
|
|
19
|
+
Gemfile.lock:1126
|
|
20
|
+
|
|
21
|
+
fail: 2 unripe, 0 unknown, 337 ok, 0 excluded
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Nothing is wrong with either of those gems. That's normal. Most new
|
|
25
|
+
versions are fine. The point is that when one isn't, you find out after
|
|
26
|
+
everyone else does, not before.
|
|
27
|
+
|
|
28
|
+
## Why bother
|
|
29
|
+
|
|
30
|
+
When a popular package gets hijacked, the bad version is usually spotted
|
|
31
|
+
and pulled within hours. In August 2026 a worm went through npm and
|
|
32
|
+
published malicious versions of
|
|
33
|
+
[444 packages](https://cybernews.com/security/npm-supply-chain-attack-shai-hulud-worm/)
|
|
34
|
+
in about four hours, stealing CI and cloud credentials from whoever
|
|
35
|
+
installed them. In March, two backdoored LiteLLM releases sat on PyPI for
|
|
36
|
+
somewhere between
|
|
37
|
+
[40 minutes and three hours](https://docs.litellm.ai/blog/security-update-march-2026).
|
|
38
|
+
Waiting a few days before taking a new version would have skipped both.
|
|
39
|
+
|
|
40
|
+
npm, pnpm, yarn, bun and uv have each added a setting for this in the last
|
|
41
|
+
year or so, and if you use one of them you should turn it on. unripe is for
|
|
42
|
+
what those settings don't do. It checks what is already in the lockfile,
|
|
43
|
+
including whatever a bot or an old tool put there. It reports on just the
|
|
44
|
+
versions a pull request adds. And it covers Go, Composer and RubyGems, which
|
|
45
|
+
have no setting of their own.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
You need Python 3.11 or newer. unripe has no dependencies, on purpose: a
|
|
50
|
+
tool that checks your supply chain shouldn't add to it.
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
uvx unripe check
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
or `pipx install unripe`, or `pip install unripe`.
|
|
57
|
+
|
|
58
|
+
## Lockfiles it reads
|
|
59
|
+
|
|
60
|
+
| File | Publish times come from |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `package-lock.json`, `npm-shrinkwrap.json` | npm registry |
|
|
63
|
+
| `pnpm-lock.yaml` | npm registry |
|
|
64
|
+
| `yarn.lock` (classic and berry) | npm registry |
|
|
65
|
+
| `bun.lock` | npm registry |
|
|
66
|
+
| `uv.lock` | the lockfile's own upload times |
|
|
67
|
+
| `poetry.lock`, `Pipfile.lock` | PyPI |
|
|
68
|
+
| `requirements*.txt`, `constraints*.txt` | PyPI (exact `==` pins only) |
|
|
69
|
+
| `Cargo.lock` | crates.io sparse index |
|
|
70
|
+
| `go.mod` | Go module proxy, honouring `GOPROXY` |
|
|
71
|
+
| `composer.lock` | the lockfile's own `time` field, else Packagist |
|
|
72
|
+
| `Gemfile.lock` | rubygems.org |
|
|
73
|
+
|
|
74
|
+
Dependencies from git, a local path, a workspace or another registry are
|
|
75
|
+
skipped, since there's nothing public to ask about them. The old binary
|
|
76
|
+
`bun.lockb` can't be read; bun 1.2 and later write a text `bun.lock`.
|
|
77
|
+
|
|
78
|
+
For Go it reads `go.mod`, not `go.sum`. Since Go 1.17, `go.mod` lists every
|
|
79
|
+
module in the build, while `go.sum` also remembers versions that aren't used.
|
|
80
|
+
|
|
81
|
+
## In GitHub Actions
|
|
82
|
+
|
|
83
|
+
```yaml
|
|
84
|
+
name: unripe
|
|
85
|
+
on: pull_request
|
|
86
|
+
|
|
87
|
+
permissions:
|
|
88
|
+
contents: read
|
|
89
|
+
|
|
90
|
+
jobs:
|
|
91
|
+
unripe:
|
|
92
|
+
runs-on: ubuntu-latest
|
|
93
|
+
steps:
|
|
94
|
+
- uses: actions/checkout@v7
|
|
95
|
+
- uses: TexWoW/unripe@v0.1.0
|
|
96
|
+
with:
|
|
97
|
+
min-age: 3d
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
On a pull request it only looks at versions the PR adds, compared with the
|
|
101
|
+
base commit. It fetches that commit if the checkout is shallow, so you don't
|
|
102
|
+
need `fetch-depth: 0`. On a push it checks everything. The report goes to the
|
|
103
|
+
job summary.
|
|
104
|
+
|
|
105
|
+
Pin the action to a full commit sha rather than a tag if you can. The action
|
|
106
|
+
runs unripe from its own checkout, so the code that runs is the commit you
|
|
107
|
+
pinned, and nothing gets downloaded from PyPI.
|
|
108
|
+
|
|
109
|
+
Inputs:
|
|
110
|
+
|
|
111
|
+
| Input | Default | |
|
|
112
|
+
|---|---|---|
|
|
113
|
+
| `min-age` | `3d` | `90m`, `72h`, `3d`, `1w` |
|
|
114
|
+
| `since` | PR base | a git ref, or `none` to check everything |
|
|
115
|
+
| `paths` | whole repo | files or directories, space or newline separated |
|
|
116
|
+
| `exclude` | | patterns, one per line |
|
|
117
|
+
| `unknown` | `fail` | set to `warn` to let unknown ages pass |
|
|
118
|
+
| `summary` | `true` | write the job summary |
|
|
119
|
+
| `sarif` | | also write SARIF to this path |
|
|
120
|
+
| `config` | | a config file other than the defaults |
|
|
121
|
+
|
|
122
|
+
If you want the results as code scanning alerts on the lockfile lines, write
|
|
123
|
+
SARIF and upload it (this needs `security-events: write`):
|
|
124
|
+
|
|
125
|
+
```yaml
|
|
126
|
+
- uses: TexWoW/unripe@v0.1.0
|
|
127
|
+
with:
|
|
128
|
+
sarif: unripe.sarif
|
|
129
|
+
- uses: github/codeql-action/upload-sarif@v4
|
|
130
|
+
if: always()
|
|
131
|
+
with:
|
|
132
|
+
sarif_file: unripe.sarif
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## As a pre-commit hook
|
|
136
|
+
|
|
137
|
+
```yaml
|
|
138
|
+
repos:
|
|
139
|
+
- repo: https://github.com/TexWoW/unripe
|
|
140
|
+
rev: v0.1.0
|
|
141
|
+
hooks:
|
|
142
|
+
- id: unripe
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
It runs when a lockfile changes. Add `args: [--since, HEAD]` if you only
|
|
146
|
+
want it to look at what the commit adds.
|
|
147
|
+
|
|
148
|
+
## Configuration
|
|
149
|
+
|
|
150
|
+
Put settings in `.unripe.toml`, or under `[tool.unripe]` in
|
|
151
|
+
`pyproject.toml`. Flags on the command line win. Excludes from both are
|
|
152
|
+
combined.
|
|
153
|
+
|
|
154
|
+
```toml
|
|
155
|
+
min-age = "3d"
|
|
156
|
+
unknown = "fail"
|
|
157
|
+
paths = ["web", "api"]
|
|
158
|
+
exclude = [
|
|
159
|
+
"npm:@myorg/*",
|
|
160
|
+
"left-pad@1.3.0",
|
|
161
|
+
]
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
An exclude is a package name or a glob. Prefix it with an ecosystem
|
|
165
|
+
(`npm:`, `pypi:`, `cargo:`, `go:`, `composer:`, `rubygems:`) to limit it to
|
|
166
|
+
one, and add `@version` to limit it to certain versions, like
|
|
167
|
+
`@types/node@20.*`. PyPI names are normalised the way PyPI does it, so
|
|
168
|
+
`Typing_Extensions` and `typing-extensions` are the same package.
|
|
169
|
+
|
|
170
|
+
## Command line
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
unripe check [paths...]
|
|
174
|
+
--min-age AGE default 3d
|
|
175
|
+
--since REF only versions added since this git ref
|
|
176
|
+
--exclude PATTERN can be repeated
|
|
177
|
+
--unknown fail|warn default fail
|
|
178
|
+
--format FORMAT text, json, sarif or markdown
|
|
179
|
+
--output FILE write the report here instead of stdout
|
|
180
|
+
--summary FILE also append a markdown report, e.g. $GITHUB_STEP_SUMMARY
|
|
181
|
+
--sarif FILE also write a SARIF report
|
|
182
|
+
--config FILE
|
|
183
|
+
--no-cache
|
|
184
|
+
--verbose list the versions that passed too
|
|
185
|
+
|
|
186
|
+
unripe explain ECOSYSTEM NAME VERSION
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`unripe` with no command runs `unripe check`. It exits 0 when everything is
|
|
190
|
+
old enough, 1 when something is too new or has an unknown age, and 2 when a
|
|
191
|
+
lockfile can't be read, no lockfile was found, or an option is wrong.
|
|
192
|
+
|
|
193
|
+
`explain` is handy for a single question:
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
$ unripe explain npm left-pad 1.3.0
|
|
197
|
+
npm left-pad 1.3.0: published 2018-04-09 01:10 UTC, 8y ago
|
|
198
|
+
old enough: the minimum age is 3d
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Details worth knowing
|
|
202
|
+
|
|
203
|
+
If a registry can't tell unripe when a version was published, because it's
|
|
204
|
+
down or rate limiting or has never heard of the version, that version counts
|
|
205
|
+
as unknown, and unknown fails. A check that quietly passes when the registry
|
|
206
|
+
is unreachable is one an attacker can turn off. Use `--unknown warn` if you'd
|
|
207
|
+
rather not.
|
|
208
|
+
|
|
209
|
+
Publish times never change, so answers are cached on disk forever, in
|
|
210
|
+
`~/.cache/unripe` (`%LOCALAPPDATA%\unripe\cache` on Windows), or wherever
|
|
211
|
+
`UNRIPE_CACHE` points. A second run on the same lockfiles makes no requests
|
|
212
|
+
at all. Each version is looked up once even if several lockfiles have it,
|
|
213
|
+
and lookups run 16 at a time. For npm, Cargo, Composer and RubyGems one
|
|
214
|
+
request covers every version of a package.
|
|
215
|
+
|
|
216
|
+
Mirrors work through `UNRIPE_NPM_REGISTRY` and `UNRIPE_PYPI_URL`. For Go,
|
|
217
|
+
unripe uses the first proxy in `GOPROXY`.
|
|
218
|
+
|
|
219
|
+
The Go check is weaker than the rest. The module proxy reports a version's
|
|
220
|
+
commit time, not when it was first published, so a tag pushed today on an
|
|
221
|
+
older commit looks old, and git lets anyone set any commit time. I haven't
|
|
222
|
+
found a public record of when the proxy first saw a version that could be
|
|
223
|
+
used instead.
|
|
224
|
+
|
|
225
|
+
`uv.lock` and `composer.lock` carry publish times themselves and unripe
|
|
226
|
+
trusts them. Someone who can edit your lockfile can change those times, but
|
|
227
|
+
they could also point it at any package they like, so that isn't something
|
|
228
|
+
unripe tries to defend against.
|
|
229
|
+
|
|
230
|
+
## Next to the built-in settings
|
|
231
|
+
|
|
232
|
+
| | Setting |
|
|
233
|
+
|---|---|
|
|
234
|
+
| npm 11.10+ | `min-release-age` in `.npmrc`, in days |
|
|
235
|
+
| pnpm 10.16+ | `minimumReleaseAge` in `pnpm-workspace.yaml`, in minutes |
|
|
236
|
+
| yarn 4.10+ | `npmMinimalAgeGate` in `.yarnrc.yml` |
|
|
237
|
+
| bun 1.3+ | `minimumReleaseAge` in `bunfig.toml`, in seconds |
|
|
238
|
+
| uv | `exclude-newer`, a date or a duration like `7d` |
|
|
239
|
+
| Cargo, Go, Composer, Bundler | nothing built in as of October 2026 |
|
|
240
|
+
|
|
241
|
+
These stop a new version at install time, which unripe can't do, so use
|
|
242
|
+
them where you can. unripe is the check in CI afterwards: one policy for the
|
|
243
|
+
whole repo, run against what actually got locked.
|
|
244
|
+
|
|
245
|
+
## Using it from Python
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
import unripe
|
|
249
|
+
|
|
250
|
+
for finding in unripe.scan(["."], min_age="3d", exclude=["npm:@myorg/*"]):
|
|
251
|
+
if finding.state != "ok":
|
|
252
|
+
print(finding.package, finding.state, finding.reason)
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Each finding's state is `ok`, `unripe`, `unknown` or `excluded`. `scan()`
|
|
256
|
+
raises `unripe.check.Problem` if a lockfile can't be read or the `since` ref
|
|
257
|
+
doesn't exist.
|
|
258
|
+
|
|
259
|
+
## Questions
|
|
260
|
+
|
|
261
|
+
**Our own packages are always new.** Exclude them, e.g. `npm:@myorg/*`.
|
|
262
|
+
|
|
263
|
+
**We use a private registry.** unripe can't see into it, so those packages
|
|
264
|
+
come back unknown. Exclude them, or use `--unknown warn`. For private Go
|
|
265
|
+
modules, point `GOPROXY` at your own proxy.
|
|
266
|
+
|
|
267
|
+
**Does this replace `npm audit` or similar?** No. Audit tools know about
|
|
268
|
+
reported vulnerabilities. unripe doesn't know anything about any package. It
|
|
269
|
+
just won't trust a version until it has been around for a while. They catch
|
|
270
|
+
different things, so run both.
|
|
271
|
+
|
|
272
|
+
**Is it slow?** The first run on a big repo takes a while. Twenty real
|
|
273
|
+
lockfiles with about 7,800 versions between them took 82 seconds cold and
|
|
274
|
+
1 second after that.
|
|
275
|
+
|
|
276
|
+
## License
|
|
277
|
+
|
|
278
|
+
MIT
|
unripe-0.1.0/action.yml
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
name: unripe
|
|
2
|
+
description: Fail when a locked dependency version was published more recently than you trust
|
|
3
|
+
author: TexWoW
|
|
4
|
+
branding:
|
|
5
|
+
icon: clock
|
|
6
|
+
color: green
|
|
7
|
+
|
|
8
|
+
inputs:
|
|
9
|
+
min-age:
|
|
10
|
+
description: How old every locked version must be, like 72h, 3d or 1w. Empty uses the config file, then 3d.
|
|
11
|
+
required: false
|
|
12
|
+
default: ""
|
|
13
|
+
since:
|
|
14
|
+
description: >
|
|
15
|
+
Only check versions added since this git ref. Empty means the pull request's base commit on
|
|
16
|
+
pull_request events and everything otherwise. "none" always checks everything.
|
|
17
|
+
required: false
|
|
18
|
+
default: ""
|
|
19
|
+
paths:
|
|
20
|
+
description: Lockfiles or directories to check, separated by spaces or new lines. Empty uses the config file, then the whole repository.
|
|
21
|
+
required: false
|
|
22
|
+
default: ""
|
|
23
|
+
exclude:
|
|
24
|
+
description: Packages to skip, one pattern per line, like `left-pad`, `@types/*` or `npm:@myorg/*`.
|
|
25
|
+
required: false
|
|
26
|
+
default: ""
|
|
27
|
+
unknown:
|
|
28
|
+
description: What a version with an unknown publish time does, fail or warn. Empty uses the config file, then fail.
|
|
29
|
+
required: false
|
|
30
|
+
default: ""
|
|
31
|
+
config:
|
|
32
|
+
description: A config file to read instead of .unripe.toml or pyproject.toml.
|
|
33
|
+
required: false
|
|
34
|
+
default: ""
|
|
35
|
+
summary:
|
|
36
|
+
description: Write a markdown report to the job summary.
|
|
37
|
+
required: false
|
|
38
|
+
default: "true"
|
|
39
|
+
sarif:
|
|
40
|
+
description: Also write a SARIF report to this path, for github/codeql-action/upload-sarif.
|
|
41
|
+
required: false
|
|
42
|
+
default: ""
|
|
43
|
+
python-version:
|
|
44
|
+
description: The Python that runs unripe. uv downloads it when the runner does not have it.
|
|
45
|
+
required: false
|
|
46
|
+
default: "3.13"
|
|
47
|
+
|
|
48
|
+
runs:
|
|
49
|
+
using: composite
|
|
50
|
+
steps:
|
|
51
|
+
- name: Set up uv
|
|
52
|
+
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
|
53
|
+
with:
|
|
54
|
+
enable-cache: false
|
|
55
|
+
ignore-empty-workdir: true
|
|
56
|
+
- name: Run unripe
|
|
57
|
+
shell: bash
|
|
58
|
+
env:
|
|
59
|
+
INPUT_MIN_AGE: ${{ inputs.min-age }}
|
|
60
|
+
INPUT_SINCE: ${{ inputs.since }}
|
|
61
|
+
INPUT_PATHS: ${{ inputs.paths }}
|
|
62
|
+
INPUT_EXCLUDE: ${{ inputs.exclude }}
|
|
63
|
+
INPUT_UNKNOWN: ${{ inputs.unknown }}
|
|
64
|
+
INPUT_CONFIG: ${{ inputs.config }}
|
|
65
|
+
INPUT_SUMMARY: ${{ inputs.summary }}
|
|
66
|
+
INPUT_SARIF: ${{ inputs.sarif }}
|
|
67
|
+
INPUT_PYTHON: ${{ inputs.python-version }}
|
|
68
|
+
PR_BASE: ${{ github.event.pull_request.base.sha }}
|
|
69
|
+
run: bash "$GITHUB_ACTION_PATH/scripts/action.sh"
|