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.
Files changed (49) hide show
  1. unripe-0.1.0/.gitattributes +2 -0
  2. unripe-0.1.0/.gitignore +8 -0
  3. unripe-0.1.0/.pre-commit-hooks.yaml +9 -0
  4. unripe-0.1.0/LICENSE +21 -0
  5. unripe-0.1.0/PKG-INFO +303 -0
  6. unripe-0.1.0/README.md +278 -0
  7. unripe-0.1.0/action.yml +69 -0
  8. unripe-0.1.0/pyproject.toml +50 -0
  9. unripe-0.1.0/scripts/action.sh +63 -0
  10. unripe-0.1.0/src/unripe/__init__.py +42 -0
  11. unripe-0.1.0/src/unripe/__main__.py +5 -0
  12. unripe-0.1.0/src/unripe/check.py +216 -0
  13. unripe-0.1.0/src/unripe/cli.py +148 -0
  14. unripe-0.1.0/src/unripe/lockfiles/__init__.py +85 -0
  15. unripe-0.1.0/src/unripe/lockfiles/javascript.py +249 -0
  16. unripe-0.1.0/src/unripe/lockfiles/others.py +126 -0
  17. unripe-0.1.0/src/unripe/lockfiles/python.py +94 -0
  18. unripe-0.1.0/src/unripe/model.py +62 -0
  19. unripe-0.1.0/src/unripe/policy.py +154 -0
  20. unripe-0.1.0/src/unripe/registries.py +310 -0
  21. unripe-0.1.0/src/unripe/report.py +310 -0
  22. unripe-0.1.0/tests/conftest.py +137 -0
  23. unripe-0.1.0/tests/test_check.py +247 -0
  24. unripe-0.1.0/tests/test_cli.py +138 -0
  25. unripe-0.1.0/tests/test_lockfiles.py +216 -0
  26. unripe-0.1.0/tests/test_policy.py +125 -0
  27. unripe-0.1.0/tests/test_registries.py +238 -0
  28. unripe-0.1.0/tests/test_report.py +118 -0
  29. unripe-0.1.0/tests/testdata/bun/bun.lock +33 -0
  30. unripe-0.1.0/tests/testdata/bun-binary/bun.lockb +0 -0
  31. unripe-0.1.0/tests/testdata/cargo/Cargo.lock +43 -0
  32. unripe-0.1.0/tests/testdata/composer/composer.lock +77 -0
  33. unripe-0.1.0/tests/testdata/go/go.mod +23 -0
  34. unripe-0.1.0/tests/testdata/live/Cargo.lock +23 -0
  35. unripe-0.1.0/tests/testdata/live/go.mod +10 -0
  36. unripe-0.1.0/tests/testdata/live/package-lock.json +110 -0
  37. unripe-0.1.0/tests/testdata/live/uv.lock +36 -0
  38. unripe-0.1.0/tests/testdata/npm-v1/package-lock.json +52 -0
  39. unripe-0.1.0/tests/testdata/npm-v3/package-lock.json +133 -0
  40. unripe-0.1.0/tests/testdata/pipenv/Pipfile.lock +56 -0
  41. unripe-0.1.0/tests/testdata/pnpm-v5/pnpm-lock.yaml +42 -0
  42. unripe-0.1.0/tests/testdata/pnpm-v6/pnpm-lock.yaml +67 -0
  43. unripe-0.1.0/tests/testdata/pnpm-v9/pnpm-lock.yaml +103 -0
  44. unripe-0.1.0/tests/testdata/poetry/poetry.lock +73 -0
  45. unripe-0.1.0/tests/testdata/requirements/requirements.txt +18 -0
  46. unripe-0.1.0/tests/testdata/ruby/Gemfile.lock +36 -0
  47. unripe-0.1.0/tests/testdata/uv/uv.lock +57 -0
  48. unripe-0.1.0/tests/testdata/yarn-berry/yarn.lock +57 -0
  49. unripe-0.1.0/tests/testdata/yarn-classic/yarn.lock +36 -0
@@ -0,0 +1,2 @@
1
+ * text=auto eol=lf
2
+ *.lockb binary
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ .venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ PLAN.md
@@ -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
@@ -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"