@preventive/upstream 1.0.0 → 1.1.0
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.
- package/advisories.d.ts +29 -15
- package/cargo.d.ts +0 -2
- package/git.d.ts +0 -2
- package/github/write.d.ts +1 -2
- package/github.d.ts +63 -9
- package/npm.d.ts +3 -3
- package/package.d.ts +0 -2
- package/package.json +3 -2
- package/semver.d.ts +0 -2
- package/soldeer.d.ts +0 -3
- package/src/advisories/github.js +34 -15
- package/src/advisories/index.js +2 -2
- package/src/advisories/npm.js +33 -6
- package/src/advisories/osv.js +10 -5
- package/src/github/read.js +91 -6
- package/src/http.js +4 -2
- package/src/lockfiles.js +113 -0
package/advisories.d.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// Hand-written against advisories.js; a change to either belongs with the
|
|
2
|
-
// other.
|
|
3
|
-
|
|
4
1
|
import type { Client, RepoName } from './github.js'
|
|
5
2
|
import type { CacheStore } from './npm.js'
|
|
6
3
|
|
|
@@ -53,6 +50,15 @@ export interface AdvisoryOptions {
|
|
|
53
50
|
// and its rows take OSV's record of their GHSA, one more request each
|
|
54
51
|
// (no GitHub client needed).
|
|
55
52
|
details?: boolean
|
|
53
|
+
// Also asks OSV of each npm package's versions, as cargo and composer
|
|
54
|
+
// packages always are, for what the registry does not have: above all
|
|
55
|
+
// the malicious packages' records (MAL-...), which it has none of, as
|
|
56
|
+
// those of npm releases taken down that GitHub's database has no
|
|
57
|
+
// advisory of. A version a registry row reports under the same advisory,
|
|
58
|
+
// its id among an OSV record's id and aliases, is left to that row,
|
|
59
|
+
// which takes the record's ids among its aliases. Sends each npm
|
|
60
|
+
// package's name and versions to OSV, so not for a private package.
|
|
61
|
+
osv?: boolean
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
// One advisory on one package, with the versions asked about that it
|
|
@@ -61,11 +67,13 @@ export interface Advisory {
|
|
|
61
67
|
ecosystem: Ecosystem
|
|
62
68
|
name: string
|
|
63
69
|
// `registry`: npm's, as `npm audit` has it, one row per vulnerable
|
|
64
|
-
// range. `osv`: OSV's records for cargo and composer,
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
70
|
+
// range. `osv`: OSV's records for cargo and composer, and with `osv` for
|
|
71
|
+
// npm, the versions they cover that no registry row reports under the
|
|
72
|
+
// same advisory; RustSec's, GitHub's and malicious packages' (MAL-...)
|
|
73
|
+
// among them; of the records naming each other as aliases, directly or
|
|
74
|
+
// through others, a version is reported under one only: RustSec's, else
|
|
75
|
+
// the GHSA, else the first by id. Each has the others' ids among its
|
|
76
|
+
// aliases.
|
|
69
77
|
// `repository`: published on the package's repository, one row per
|
|
70
78
|
// range, holding only the versions the others' answer does not report
|
|
71
79
|
// under that GHSA. The only source for `soldeer` and `github`, where
|
|
@@ -84,6 +92,8 @@ export interface Advisory {
|
|
|
84
92
|
// (https://osv.dev/vulnerability/RUSTSEC-...). Built, never fetched: only
|
|
85
93
|
// a registry row without a GHSA (npm:<id>) has none.
|
|
86
94
|
url?: string
|
|
95
|
+
// The advisory's other ids: an `osv` row's record's own and its group's,
|
|
96
|
+
// and with `osv`, a registry row's OSV records' of it.
|
|
87
97
|
aliases: string[]
|
|
88
98
|
title?: string
|
|
89
99
|
// With `details`, the advisory's full text, Markdown as published: an
|
|
@@ -113,14 +123,18 @@ export interface Advisory {
|
|
|
113
123
|
// merged, and must not name two repositories. npm is asked 250 names a
|
|
114
124
|
// request, OSV 1000 versions, Soldeer one project, and with `details`, OSV
|
|
115
125
|
// one GHSA among npm's rows; a project Soldeer does not have or that names
|
|
116
|
-
// no GitHub repository,
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
126
|
+
// no GitHub repository, a repository gone or blocked, and one GitHub
|
|
127
|
+
// redirects anywhere but to a repository by its id, add nothing, and a
|
|
128
|
+
// GHSA OSV does not have adds no `details`. A repository renamed or
|
|
129
|
+
// transferred, which GitHub redirects to it by its id, is listed by the
|
|
130
|
+
// name it has now, as getRepo's `followRedirect` (github.js) finds it, its
|
|
131
|
+
// rows' `url` on it. Any other failure, a malformed answer, or one about
|
|
132
|
+
// something not asked, throws: nothing is left out quietly.
|
|
120
133
|
// A repository's listing is kept for 90 minutes, as one entry for every
|
|
121
|
-
// spelling of
|
|
122
|
-
//
|
|
123
|
-
//
|
|
134
|
+
// spelling of the name it is asked by, one renamed or transferred too,
|
|
135
|
+
// holding only what rows are made from, `details` included: in
|
|
136
|
+
// setCacheDir's cache gzipped, in a `cache` store as it is, or with
|
|
137
|
+
// `cache` false, nowhere. 90 minutes is how late an advisory its
|
|
124
138
|
// maintainer publishes can be seen. One gone or malformed is not kept.
|
|
125
139
|
// Sorted by ecosystem and name. Versions are matched by npm's semver, from
|
|
126
140
|
// the npm beside node, or by the semver peer where there is no npm.
|
package/cargo.d.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// Hand-written against cargo.js; a change to either belongs with the other.
|
|
2
|
-
//
|
|
3
1
|
// A name is asserted to be a crate name, a version an exact semver
|
|
4
2
|
// version, and a checksum 64 lowercase hex digits, before any request or
|
|
5
3
|
// cache path. The cache is setCacheDir's (npm.js).
|
package/git.d.ts
CHANGED
package/github/write.d.ts
CHANGED
package/github.d.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// Hand-written against github.js; a change to either belongs with the other.
|
|
2
|
-
//
|
|
3
1
|
// Every method checks its arguments hard before any request is built: an
|
|
4
2
|
// options object holding only the keys listed here, a repo that is
|
|
5
3
|
// `owner/name` by GitHub's rules, branches and tags that git would take,
|
|
@@ -7,11 +5,12 @@
|
|
|
7
5
|
// component, titles and headlines on one line. A bad one is a rejection
|
|
8
6
|
// naming the method; nothing is sent. Every URL is built from checked,
|
|
9
7
|
// encoded segments and has to come back out of URL parsing unchanged.
|
|
10
|
-
// Redirects are refused,
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// message
|
|
8
|
+
// Redirects are refused, so an answer is about the repo asked for, except
|
|
9
|
+
// the tarballs' and, where asked, getRepo's for a repo renamed or
|
|
10
|
+
// transferred. A response is read up to a size limit (64 MiB of JSON, 128
|
|
11
|
+
// MiB of a file, 512 MiB of a tarball) and within a timeout (30 seconds, 5
|
|
12
|
+
// minutes for a tarball), and what it says goes into an error message
|
|
13
|
+
// escaped.
|
|
15
14
|
|
|
16
15
|
// `owner/name`.
|
|
17
16
|
export type RepoName = string
|
|
@@ -35,14 +34,62 @@ export interface RepoDirEntry {
|
|
|
35
34
|
sha: string
|
|
36
35
|
}
|
|
37
36
|
|
|
38
|
-
// A
|
|
37
|
+
// A lockfile's format, by the tool that writes it, with the names it
|
|
38
|
+
// writes one under and those of the project files it locks as
|
|
39
|
+
// src/lockfiles.js lists them. Those @preventive/lockfile reads are as it
|
|
40
|
+
// names the modules that do, yarn as yarn1.js, which reads Yarn 1's alone:
|
|
41
|
+
// only the text tells a later Yarn's apart.
|
|
42
|
+
export type LockfileFormat =
|
|
43
|
+
| 'pnpm' | 'npm' | 'yarn' | 'bun' | 'deno'
|
|
44
|
+
| 'uv' | 'poetry' | 'pylock' | 'pipenv' | 'pdm' | 'pixi' | 'conda-lock'
|
|
45
|
+
| 'bundler' | 'composer' | 'cargo' | 'soldeer' | 'foundry'
|
|
46
|
+
| 'cocoapods' | 'swiftpm' | 'carthage'
|
|
47
|
+
| 'go' | 'dep' | 'nuget' | 'paket' | 'gradle' | 'pub' | 'mix' | 'rebar' | 'julia'
|
|
48
|
+
| 'stack' | 'cabal' | 'shards' | 'opam' | 'nix' | 'conan' | 'bazel' | 'helm' | 'renv' | 'terraform'
|
|
49
|
+
|
|
50
|
+
// A file by a lockfile's name: `path` from the repo's root, as getRepoFile
|
|
51
|
+
// takes it, and `sha` its blob's id.
|
|
52
|
+
export interface RepoLockfile {
|
|
53
|
+
path: string
|
|
54
|
+
format: LockfileFormat
|
|
55
|
+
sha: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A file by a project file's name, as RepoLockfile is, and the formats of
|
|
59
|
+
// the lockfiles that may lock its project, in src/lockfiles.js' order:
|
|
60
|
+
// whether one does, and where, is the tool's to say, a workspace's at its
|
|
61
|
+
// root.
|
|
62
|
+
export interface RepoProjectFile {
|
|
63
|
+
path: string
|
|
64
|
+
formats: LockfileFormat[]
|
|
65
|
+
sha: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A failed request: `status` is the HTTP status GitHub answered with, and
|
|
69
|
+
// `headers` its response's (a redirect's `location` among them), on every
|
|
70
|
+
// call but a GraphQL one (github/write.js).
|
|
39
71
|
export class HttpError extends Error {
|
|
40
72
|
name: 'HttpError'
|
|
41
73
|
status: number
|
|
74
|
+
headers?: Headers
|
|
42
75
|
}
|
|
43
76
|
|
|
44
77
|
// Reads only.
|
|
45
78
|
export interface Client {
|
|
79
|
+
// Every file by a lockfile's name, and every project file of a tool that
|
|
80
|
+
// writes one, in the full commit `sha`, or under its `directory`, found as
|
|
81
|
+
// getRepoTreeId finds it, each list sorted by path; and a project file no
|
|
82
|
+
// name tells, by a lockfile beside it named after it: a Python script
|
|
83
|
+
// `uv lock --script` locked, or a Stack or Cabal project file of another
|
|
84
|
+
// name than stack.yaml or cabal.project. From one listing of the tree
|
|
85
|
+
// with every tree under it, each hashed back to its id, so none is left
|
|
86
|
+
// out or changed; where GitHub cuts that short, as it does past 100,000
|
|
87
|
+
// entries, from the tree's own listing and then each subtree's in turn.
|
|
88
|
+
// Not looked in: a submodule, which is another repo's commit, or a
|
|
89
|
+
// `node_modules`, of packages installed. A symlink is no file. Refused
|
|
90
|
+
// where a path found is one getRepoFile does not take, such as one with a
|
|
91
|
+
// control character in it.
|
|
92
|
+
findRepoLockfiles(options: { repo: RepoName; sha: string; directory?: string }): Promise<{ lockfiles: RepoLockfile[]; projects: RepoProjectFile[] }>
|
|
46
93
|
// An advisory by its GHSA id. With `repo`, that repository's published
|
|
47
94
|
// copy: the maintainer's latest text, there before GitHub reviews it,
|
|
48
95
|
// and shaped as a repository advisory (`state`, `patched_versions`, no
|
|
@@ -58,7 +105,14 @@ export interface Client {
|
|
|
58
105
|
// Refused unless GitHub answers with that pull request in that repo.
|
|
59
106
|
getPullRequest(options: { repo: RepoName; number: number }): Promise<{ title: string; status: PullRequestStatus }>
|
|
60
107
|
// GitHub's repository object, refused unless it is the repo asked for.
|
|
61
|
-
|
|
108
|
+
// With `followRedirect`, a repository renamed or transferred, which GitHub
|
|
109
|
+
// answers with a redirect to it by its id
|
|
110
|
+
// (`https://api.github.com/repositories/<id>`), is asked for there, with
|
|
111
|
+
// the same token, once: the repository as it is now, refused unless it
|
|
112
|
+
// has that id and a `full_name` that is `owner/name`, the name to ask its
|
|
113
|
+
// other calls with. A redirect anywhere else, or a second, is refused as
|
|
114
|
+
// without it: an HttpError with the redirect's status.
|
|
115
|
+
getRepo(options: { repo: RepoName; followRedirect?: boolean }): Promise<any>
|
|
62
116
|
// A file's contents as text, at `ref` or the default branch. Refused for
|
|
63
117
|
// a directory, a symlink or a submodule, unless the bytes hash to the
|
|
64
118
|
// blob GitHub names for the path, and unless valid UTF-8.
|
package/npm.d.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// Hand-written against npm.js; a change to either belongs with the other.
|
|
2
|
-
//
|
|
3
1
|
// Every call that reaches the registry first asserts that the name is a
|
|
4
2
|
// string of the shape npm takes, and a version a string that npm's
|
|
5
3
|
// semver.valid answers with unchanged; neither goes near a request, or a
|
|
@@ -73,10 +71,12 @@ export interface CacheOptions {
|
|
|
73
71
|
}
|
|
74
72
|
|
|
75
73
|
// A failed request: `status` is the HTTP status the registry answered
|
|
76
|
-
// with (404 for a package or version it does not have)
|
|
74
|
+
// with (404 for a package or version it does not have), and `headers` its
|
|
75
|
+
// response's.
|
|
77
76
|
export class HttpError extends Error {
|
|
78
77
|
name: 'HttpError'
|
|
79
78
|
status: number
|
|
79
|
+
headers?: Headers
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
// A package's GitHub repo as its registry metadata names it -- getRepo
|
package/package.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/upstream",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Minimal npm, crates.io
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Minimal npm, crates.io, Soldeer, GitHub and git checkout clients",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
"./advisories.js": {
|
|
@@ -85,6 +85,7 @@
|
|
|
85
85
|
"src/github/read.js",
|
|
86
86
|
"src/github/write.js",
|
|
87
87
|
"src/http.js",
|
|
88
|
+
"src/lockfiles.js",
|
|
88
89
|
"src/npm/cacache.js",
|
|
89
90
|
"src/npm/registry.js",
|
|
90
91
|
"src/npm/repos.js",
|
package/semver.d.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// Hand-written against semver.js; a change to either belongs with the other.
|
|
2
|
-
|
|
3
1
|
// npm's own semver, borrowed from the npm beside node and passed through
|
|
4
2
|
// as it is; where there is no npm, the `semver` package, an optional peer
|
|
5
3
|
// dependency. Without either, every call throws, except valid() on a plain
|
package/soldeer.d.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// Hand-written against soldeer.js; a change to either belongs with the
|
|
2
|
-
// other.
|
|
3
|
-
//
|
|
4
1
|
// The name is asserted to be one Soldeer takes, the version a string of
|
|
5
2
|
// letters, digits, `.`, `_`, `+` and `-`, and the checksum 64 lowercase hex
|
|
6
3
|
// digits, before any request or cache path. The cache is setCacheDir's
|
package/src/advisories/github.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
|
|
3
|
-
import { assertRepo, assertSoldeerName, assertSoldeerVersion, assertion, isGhsa, isRefName, isStrings } from '../args.js'
|
|
3
|
+
import { assertRepo, assertSoldeerName, assertSoldeerVersion, assertion, isGhsa, isRefName, isRepo, isStrings } from '../args.js'
|
|
4
4
|
import { readRecord, writeRecord } from '../cache.js'
|
|
5
5
|
import { isGone } from '../github/client.js'
|
|
6
|
-
import { recover } from '../http.js'
|
|
6
|
+
import { HttpError, recover } from '../http.js'
|
|
7
7
|
import { pool } from '../pool.js'
|
|
8
8
|
import { isExactVersion } from '../semver.js'
|
|
9
9
|
import { advisoryUrl, covered, detailsOf, isText, mayBeInRange, metrics } from './common.js'
|
|
@@ -21,7 +21,7 @@ const isRepoAdvisory = (advisory) => advisory && typeof advisory === 'object' &&
|
|
|
21
21
|
&& advisory.state === 'published' && isText(advisory.summary)
|
|
22
22
|
&& (advisory.vulnerabilities == null || Array.isArray(advisory.vulnerabilities)) && (advisory.cwe_ids == null || isStrings(advisory.cwe_ids))
|
|
23
23
|
|
|
24
|
-
export const assertClient = assertion('a GitHub client from createClient', (value) => typeof value?.listRepoAdvisories === 'function')
|
|
24
|
+
export const assertClient = assertion('a GitHub client from createClient', (value) => typeof value?.listRepoAdvisories === 'function' && typeof value.getRepo === 'function')
|
|
25
25
|
|
|
26
26
|
// A maintainer may give a vulnerable range only its lower bound, and the
|
|
27
27
|
// version that fixed it apart (`>= 5.0.0-beta.1`, patched in `5.0.0-rc.2`),
|
|
@@ -67,18 +67,37 @@ const isDigest = (advisory) => advisory && typeof advisory === 'object' && isGhs
|
|
|
67
67
|
&& (advisory.description === undefined || isText(advisory.description))
|
|
68
68
|
&& Array.isArray(advisory.ranges) && advisory.ranges.every((entry) => typeof entry?.range === 'string')
|
|
69
69
|
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
70
|
+
// GitHub answers for a repository renamed or transferred with a redirect,
|
|
71
|
+
// which getRepo follows to the repository it is now, listed by that name.
|
|
72
|
+
const isRedirect = (err) => err instanceof HttpError && err.status >= 300 && err.status < 400
|
|
73
|
+
|
|
74
|
+
async function listingOf(github, repo) {
|
|
75
|
+
try {
|
|
76
|
+
return { repo, list: await github.listRepoAdvisories({ repo }) }
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (!isRedirect(err)) throw err
|
|
79
|
+
}
|
|
80
|
+
const moved = (await github.getRepo({ repo, followRedirect: true })).full_name
|
|
81
|
+
return { repo: moved, list: await github.listRepoAdvisories({ repo: moved }) }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A repository's listing, digested, through the cache, and the repository
|
|
85
|
+
// it is of: GitHub's names are case-insensitive, so one entry answers every
|
|
86
|
+
// spelling, and one renamed or transferred is kept under the name asked,
|
|
87
|
+
// with `repo` the one it has now. A repository gone or blocked, or
|
|
88
|
+
// redirected anywhere getRepo does not follow, has none, and that is not
|
|
89
|
+
// kept.
|
|
73
90
|
async function listAdvisories(github, repo, cache) {
|
|
74
91
|
const name = repo.toLowerCase()
|
|
75
92
|
const entry = await readRecord(DIR, name, cache, LISTING_TTL_MS)
|
|
76
|
-
if (entry?.v === VERSION && Array.isArray(entry.advisories) && entry.advisories.every(isDigest))
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
if (
|
|
81
|
-
|
|
93
|
+
if (entry?.v === VERSION && (entry.repo === undefined || isRepo(entry.repo)) && Array.isArray(entry.advisories) && entry.advisories.every(isDigest)) {
|
|
94
|
+
return { repo: entry.repo ?? repo, advisories: entry.advisories }
|
|
95
|
+
}
|
|
96
|
+
const found = await listingOf(github, repo).catch(recover((err) => isGone(err) || isRedirect(err), null))
|
|
97
|
+
if (found === null) return { repo, advisories: [] }
|
|
98
|
+
const advisories = digest(found.repo, found.list)
|
|
99
|
+
if (advisories.every(isDigest)) await writeRecord(DIR, name, { v: VERSION, ...(found.repo !== repo && { repo: found.repo }), advisories }, cache)
|
|
100
|
+
return { repo: found.repo, advisories }
|
|
82
101
|
}
|
|
83
102
|
|
|
84
103
|
// GitHub's `>= 1.0.0, < 1.2.6` is npm's with the commas dropped.
|
|
@@ -109,10 +128,10 @@ function fromRepository(repo, name, advisory, range, asked, { covers, details })
|
|
|
109
128
|
async function repositoryAdvisories(github, asked, { repoOf, takes = () => true, covers, details, cache }) {
|
|
110
129
|
// GitHub's names are case-insensitive: one spelling asks for all.
|
|
111
130
|
const namesOf = Map.groupBy([...asked.keys()].filter(repoOf), (name) => repoOf(name).toLowerCase())
|
|
112
|
-
const listed = await pool([...namesOf.values()], REPOS_AT_ONCE, async (names) => ({
|
|
131
|
+
const listed = await pool([...namesOf.values()], REPOS_AT_ONCE, async (names) => ({ names, ...(await listAdvisories(github, repoOf(names[0]), cache)) }))
|
|
113
132
|
const rows = new Map()
|
|
114
|
-
for (const { repo, names,
|
|
115
|
-
for (const advisory of
|
|
133
|
+
for (const { repo, names, advisories } of listed) {
|
|
134
|
+
for (const advisory of advisories) {
|
|
116
135
|
for (const { range, ...pkg } of advisory.ranges) {
|
|
117
136
|
for (const name of names.filter((candidate) => takes(candidate, pkg))) {
|
|
118
137
|
rows.set(`${name} ${advisory.ghsa} ${range}`, fromRepository(repo, name, advisory, range, asked.get(name), { covers, details }))
|
package/src/advisories/index.js
CHANGED
|
@@ -35,7 +35,7 @@ function collect(packages) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
export async function advisories(packages, options = {}) {
|
|
38
|
-
assertArgs('advisories', options, { github: optional(assertClient), repoAdvisories: optional(assertBoolean), details: optional(assertBoolean), cache: optional(assertCache) })
|
|
38
|
+
assertArgs('advisories', options, { github: optional(assertClient), repoAdvisories: optional(assertBoolean), details: optional(assertBoolean), osv: optional(assertBoolean), cache: optional(assertCache) })
|
|
39
39
|
assert.ok(!options.repoAdvisories || options.github, 'advisories: repoAdvisories needs a github client')
|
|
40
40
|
const byEcosystem = collect(packages)
|
|
41
41
|
for (const ecosystem of byEcosystem.keys()) assert.ok(!ECOSYSTEMS[ecosystem].repositoryOnly || options.github, `advisories: ${ecosystem} packages need a github client`)
|
|
@@ -44,7 +44,7 @@ export async function advisories(packages, options = {}) {
|
|
|
44
44
|
const names = [...named.keys()].toSorted()
|
|
45
45
|
const asked = new Map(names.map((name) => [name, [...named.get(name).versions].toSorted(compare)]))
|
|
46
46
|
const known = new Map(names.filter((name) => named.get(name).github).map((name) => [name, named.get(name).github]))
|
|
47
|
-
const rows = await find(asked, { github: options.github, repoAdvisories: options.repoAdvisories === true, details: options.details === true, known, cache: options.cache })
|
|
47
|
+
const rows = await find(asked, { github: options.github, repoAdvisories: options.repoAdvisories === true, details: options.details === true, osv: options.osv === true, known, cache: options.cache })
|
|
48
48
|
return rows.map((row) => ({ ecosystem, ...row }))
|
|
49
49
|
}))
|
|
50
50
|
return found.flat().filter((row) => row.versions.length > 0).toSorted((a, b) => order(a.ecosystem, b.ecosystem) || order(a.name, b.name))
|
package/src/advisories/npm.js
CHANGED
|
@@ -6,7 +6,7 @@ import { chunks, pool } from '../pool.js'
|
|
|
6
6
|
import { compareVersions } from '../semver.js'
|
|
7
7
|
import { advisoryUrl, covered, detailsOf, isText, metrics } from './common.js'
|
|
8
8
|
import { withRepositories } from './github.js'
|
|
9
|
-
import { RECORDS_AT_ONCE, getVuln } from './osv.js'
|
|
9
|
+
import { RECORDS_AT_ONCE, getVuln, osvRows } from './osv.js'
|
|
10
10
|
import { npmRepos } from './repos.js'
|
|
11
11
|
|
|
12
12
|
const BULK_URL = buildUrl(NPM_REGISTRY, ['-', 'npm', 'v1', 'security', 'advisories', 'bulk'])
|
|
@@ -52,21 +52,48 @@ async function registryAdvisories(asked) {
|
|
|
52
52
|
|
|
53
53
|
// The registry's rows carry no text: each GHSA's is OSV's record of it,
|
|
54
54
|
// GitHub's advisory database as published, asked once for the rows that
|
|
55
|
-
// cover an asked version
|
|
55
|
+
// cover an asked version, but where `osv` already read it. One OSV does
|
|
56
|
+
// not have yet has none.
|
|
56
57
|
async function withDetails(rows) {
|
|
57
|
-
const
|
|
58
|
+
const asks = (row) => row.source === 'registry' && row.details === undefined
|
|
59
|
+
const ghsas = [...new Set(rows.filter((row) => asks(row) && row.ghsa && row.versions.length > 0).map((row) => row.ghsa))]
|
|
58
60
|
const records = await pool(ghsas, RECORDS_AT_ONCE, (ghsa) => getVuln(ghsa).catch(recover(isNotFound, undefined)))
|
|
59
61
|
const text = new Map(ghsas.map((ghsa, i) => [ghsa, records[i]?.details]))
|
|
60
|
-
return rows.map((row) => ({ ...row, ...detailsOf(text.get(row.ghsa), row.ghsa) }))
|
|
62
|
+
return rows.map((row) => (asks(row) ? { ...row, ...detailsOf(text.get(row.ghsa), row.ghsa) } : row))
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
//
|
|
65
|
+
// With `osv`, OSV's records of the asked versions besides the registry's
|
|
66
|
+
// rows, the malicious packages' (MAL-...), which the registry has none
|
|
67
|
+
// of, among them. A version a registry row reports under the same
|
|
68
|
+
// advisory, the row's id one of an OSV row's ids or aliases, is left to
|
|
69
|
+
// that row, which takes the OSV row's ids among its aliases, and its text
|
|
70
|
+
// where it is the same record; an OSV row keeps the versions no such row
|
|
71
|
+
// reports.
|
|
72
|
+
function withOsv(registry, osv) {
|
|
73
|
+
const rows = registry.map((row) => ({ ...row }))
|
|
74
|
+
const added = []
|
|
75
|
+
for (const record of osv) {
|
|
76
|
+
const ids = [record.id, ...record.aliases]
|
|
77
|
+
const same = rows.filter((row) => row.name === record.name && ids.includes(row.id) && row.versions.some((version) => record.versions.includes(version)))
|
|
78
|
+
for (const row of same) {
|
|
79
|
+
row.aliases = [...new Set([...row.aliases, ...ids])].filter((id) => id !== row.id)
|
|
80
|
+
if (record.id === row.id && record.details !== undefined) row.details = record.details
|
|
81
|
+
}
|
|
82
|
+
const versions = record.versions.filter((version) => !same.some((row) => row.versions.includes(version)))
|
|
83
|
+
if (versions.length > 0) added.push({ ...record, versions })
|
|
84
|
+
}
|
|
85
|
+
return [...rows, ...added]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// What `npm audit` asks the registry, one row per vulnerable range, and
|
|
89
|
+
// with `osv`, what OSV has besides.
|
|
64
90
|
export const NPM = {
|
|
65
91
|
assertName: assertPackageName,
|
|
66
92
|
assertVersion: assertPackageVersion,
|
|
67
93
|
compare: compareVersions,
|
|
68
94
|
advisories: async (asked, options) => {
|
|
69
|
-
const
|
|
95
|
+
const [registry, osv] = await Promise.all([registryAdvisories(asked), options.osv ? osvRows({ osv: 'npm' }, asked, options) : []])
|
|
96
|
+
const rows = withOsv(registry, osv)
|
|
70
97
|
return await withRepositories(options.details ? await withDetails(rows) : rows, asked, options, { ecosystem: 'npm', lookUp: npmRepos })
|
|
71
98
|
},
|
|
72
99
|
}
|
package/src/advisories/osv.js
CHANGED
|
@@ -84,9 +84,9 @@ function toAdvisory(ecosystem, name, versions, record, details) {
|
|
|
84
84
|
// publishes comes back under each one's id, the records naming each other
|
|
85
85
|
// as aliases, either way round, directly or through others: one group, in
|
|
86
86
|
// which each version is reported under the first answered for it, and
|
|
87
|
-
// each record has the others' ids as aliases.
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
// each record has the others' ids as aliases. `osv` is OSV's name for the
|
|
88
|
+
// ecosystem.
|
|
89
|
+
export async function osvRows({ osv }, asked, { details }) {
|
|
90
90
|
const list = [...asked].flatMap(([name, versions]) => versions.map((version) => ({ name, version })))
|
|
91
91
|
const hits = new Map() // id -> name -> versions, in `list` order
|
|
92
92
|
for (const chunk of chunks(list, QUERIES_PER_REQUEST)) {
|
|
@@ -118,9 +118,14 @@ async function osvAdvisories(ecosystem, asked, options) {
|
|
|
118
118
|
const aliases = [...new Set([...record.aliases, ...group.map((other) => other.id)])].filter((id) => id !== record.id)
|
|
119
119
|
for (const [name, versions] of hits.get(record.id)) {
|
|
120
120
|
const rest = [...versions].filter((version) => !above.some((other) => hits.get(other.id).get(name)?.has(version)))
|
|
121
|
-
if (rest.length > 0) rows.push(toAdvisory(osv, name, rest, { ...record, aliases },
|
|
121
|
+
if (rest.length > 0) rows.push(toAdvisory(osv, name, rest, { ...record, aliases }, details))
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
rows.sort((a, b) => order(a.name, b.name) || order(a.id, b.id))
|
|
125
|
-
return
|
|
125
|
+
return rows
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function osvAdvisories(ecosystem, asked, options) {
|
|
129
|
+
const { github, lookUp, covers } = ecosystem
|
|
130
|
+
return await withRepositories(await osvRows(ecosystem, asked, options), asked, options, { ecosystem: github, lookUp, covers })
|
|
126
131
|
}
|
package/src/github/read.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import { Buffer } from 'node:buffer'
|
|
3
3
|
|
|
4
|
-
import { assertArgs, assertGhsa, assertLogin, assertNumber, assertPath, assertRef, assertRepo, assertSha, assertTagName, assertTreeId, isSha, isSha1, isTagName, optional, sameName, show } from '../args.js'
|
|
4
|
+
import { assertArgs, assertBoolean, assertGhsa, assertLogin, assertNumber, assertPath, assertRef, assertRepo, assertSha, assertTagName, assertTreeId, isRepo, isSha, isSha1, isTagName, optional, sameName, show } from '../args.js'
|
|
5
5
|
import { verifiedDownload } from '../download.js'
|
|
6
|
-
import { decode, encodeSegment, requestWithHeaders } from '../http.js'
|
|
6
|
+
import { HttpError, decode, encodeSegment, requestWithHeaders } from '../http.js'
|
|
7
|
+
import { FORMATS, lockfileOf, projectFormats } from '../lockfiles.js'
|
|
7
8
|
import { gitTreeOfListing, objectId } from '../tree.js'
|
|
8
9
|
import { api, bindMethods, call, clientHeaders, isGone, repoApi } from './client.js'
|
|
9
10
|
|
|
@@ -48,10 +49,29 @@ async function listUserRepos(headers, options) {
|
|
|
48
49
|
return await paginate('listUserRepos', headers, (paging) => api(['user', 'repos'], { ...paging, sort: 'full_name' }), options?.maxPages)
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
// Where GitHub's API sends a repository renamed or transferred: to that
|
|
53
|
+
// repository by its id.
|
|
54
|
+
const REPO_BY_ID = /^https:\/\/api\.github\.com\/repositories\/([1-9]\d{0,14})$/u
|
|
55
|
+
const REDIRECTS = new Set([301, 302, 307, 308])
|
|
56
|
+
|
|
57
|
+
// With `followRedirect`, GitHub's redirect for a repository renamed or
|
|
58
|
+
// transferred is followed, once, on its API alone, and the answer held to
|
|
59
|
+
// the id it names rather than to the name asked; any other redirect, or a
|
|
60
|
+
// second, is refused as without it.
|
|
51
61
|
async function getRepo(headers, options) {
|
|
52
|
-
assertArgs('getRepo', options, { repo: assertRepo })
|
|
53
|
-
const
|
|
54
|
-
|
|
62
|
+
assertArgs('getRepo', options, { repo: assertRepo, followRedirect: optional(assertBoolean) })
|
|
63
|
+
const { repo, followRedirect } = options
|
|
64
|
+
let info
|
|
65
|
+
try {
|
|
66
|
+
info = await call(headers, repoApi(repo))
|
|
67
|
+
} catch (err) {
|
|
68
|
+
const id = followRedirect && err instanceof HttpError && REDIRECTS.has(err.status) ? REPO_BY_ID.exec(err.headers.get('location') ?? '')?.[1] : undefined
|
|
69
|
+
if (id === undefined) throw err
|
|
70
|
+
const moved = await call(headers, api(['repositories', id]))
|
|
71
|
+
assert.ok(moved?.id === Number(id) && isRepo(moved.full_name), `getRepo: ${repo} redirected to repository ${id}, answered for ${show(moved?.id)}, ${show(moved?.full_name)}`)
|
|
72
|
+
return moved
|
|
73
|
+
}
|
|
74
|
+
assert.ok(sameName(info?.full_name, repo), `getRepo: answered for ${show(info?.full_name)}, not ${repo}`)
|
|
55
75
|
return info
|
|
56
76
|
}
|
|
57
77
|
|
|
@@ -175,6 +195,71 @@ async function listRepoDir(headers, options) {
|
|
|
175
195
|
return await listTree('listRepoDir', headers, options.repo, tree)
|
|
176
196
|
}
|
|
177
197
|
|
|
198
|
+
// A tree and every tree under it in one listing, their entries by paths
|
|
199
|
+
// from it, or null where GitHub cut it short, as it does past 100,000
|
|
200
|
+
// entries or 7 MB. Each tree in it, the top one too, has to hash from the
|
|
201
|
+
// entries listed in it, so none is left out, changed or added: an entry is
|
|
202
|
+
// filed under the path before its last `/`, a tree's own with a `/` after
|
|
203
|
+
// it, so `/x` or `a//x` is in no tree.
|
|
204
|
+
async function listTreeDeep(method, headers, repo, tree) {
|
|
205
|
+
const listing = await call(headers, repoApi(repo, ['git', 'trees', tree], { recursive: 1 }))
|
|
206
|
+
if (listing?.truncated === true) return null
|
|
207
|
+
const entries = Array.isArray(listing?.tree) && listing.tree.every((entry) => typeof entry?.path === 'string') ? listing.tree : []
|
|
208
|
+
const dirs = new Map([['', { sha: tree, listed: [] }], ...entries.filter((entry) => entry.type === 'tree').map((entry) => [`${entry.path}/`, { sha: entry.sha, listed: [] }])])
|
|
209
|
+
const filed = entries.every(({ path, mode, type, sha }) => {
|
|
210
|
+
const at = path.lastIndexOf('/') + 1
|
|
211
|
+
const dir = dirs.get(path.slice(0, at))
|
|
212
|
+
dir?.listed.push({ path: path.slice(at), mode, type, sha })
|
|
213
|
+
return dir !== undefined
|
|
214
|
+
})
|
|
215
|
+
assert.ok(filed && [...dirs.values()].every(({ sha, listed }) => gitTreeOfListing(listed) === sha), `${method}: GitHub's listing of tree ${tree} in ${repo}, with the trees under it, is not that tree`)
|
|
216
|
+
return entries
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Each file in `tree`, which is at `base` in the repo, by its path, its
|
|
220
|
+
// name and its blob's id: from the tree's listing with every tree under
|
|
221
|
+
// it, or, where GitHub cuts that short, from its own and then each
|
|
222
|
+
// subtree's in turn, as GitHub advises for a large tree. A node_modules,
|
|
223
|
+
// of packages installed, is not the repo's own.
|
|
224
|
+
async function* filesIn(method, headers, repo, tree, base) {
|
|
225
|
+
const deep = await listTreeDeep(method, headers, repo, tree)
|
|
226
|
+
for (const { path, mode, type, sha } of deep ?? await listTree(method, headers, repo, tree)) {
|
|
227
|
+
const names = path.split('/')
|
|
228
|
+
if (names.includes('node_modules')) continue
|
|
229
|
+
if (type === 'blob' && mode !== '120000') yield { path: `${base}${path}`, name: names.at(-1), sha }
|
|
230
|
+
else if (deep === null && type === 'tree') yield* filesIn(method, headers, repo, sha, `${base}${path}/`)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const byPath = (a, b) => (a.path < b.path ? -1 : 1)
|
|
235
|
+
|
|
236
|
+
async function findRepoLockfiles(headers, options) {
|
|
237
|
+
assertArgs('findRepoLockfiles', options, { repo: assertRepo, sha: assertSha, directory: optional(assertPath) })
|
|
238
|
+
const { repo, sha, directory } = options
|
|
239
|
+
const tree = await treeAt('findRepoLockfiles', headers, options)
|
|
240
|
+
const lockfiles = []
|
|
241
|
+
const projects = new Map()
|
|
242
|
+
const files = new Map()
|
|
243
|
+
const locked = []
|
|
244
|
+
for await (const { path, name, sha: blob } of filesIn('findRepoLockfiles', headers, repo, tree, directory === undefined ? '' : `${directory}/`)) {
|
|
245
|
+
files.set(path, blob)
|
|
246
|
+
const lockfile = lockfileOf(name)
|
|
247
|
+
const formats = projectFormats(name)
|
|
248
|
+
if (lockfile === undefined && formats.length === 0) continue
|
|
249
|
+
assertPath('findRepoLockfiles', `${repo}@${sha}'s ${lockfile === undefined ? 'project file' : 'lockfile'}`, path)
|
|
250
|
+
if (lockfile !== undefined) lockfiles.push({ path, format: lockfile.format, sha: blob })
|
|
251
|
+
if (lockfile?.locks !== undefined) locked.push({ path: `${path.slice(0, -name.length)}${lockfile.locks}`, format: lockfile.format })
|
|
252
|
+
if (formats.length > 0) projects.set(path, { path, formats, sha: blob })
|
|
253
|
+
}
|
|
254
|
+
// A file a lockfile beside it locks is a project file of that format too.
|
|
255
|
+
for (const { path, format } of locked) {
|
|
256
|
+
if (!files.has(path)) continue
|
|
257
|
+
const formats = projects.get(path)?.formats ?? []
|
|
258
|
+
projects.set(path, { path, formats: Object.keys(FORMATS).filter((known) => known === format || formats.includes(known)), sha: files.get(path) })
|
|
259
|
+
}
|
|
260
|
+
return { lockfiles: lockfiles.toSorted(byPath), projects: [...projects.values()].toSorted(byPath) }
|
|
261
|
+
}
|
|
262
|
+
|
|
178
263
|
async function getPullRequest(headers, options) {
|
|
179
264
|
assertArgs('getPullRequest', options, { repo: assertRepo, number: assertNumber })
|
|
180
265
|
const { repo, number } = options
|
|
@@ -219,5 +304,5 @@ async function listRepoAdvisories(headers, options) {
|
|
|
219
304
|
return (await Array.fromAsync(cursorPages('listRepoAdvisories', headers, pageUrl))).flat()
|
|
220
305
|
}
|
|
221
306
|
|
|
222
|
-
export const readMethods = { getCurrentUser, listUserRepos, getRepo, getRepoHead, getRepoTag, listRepoTags, getRepoFile, getRepoTarball, getRepoTreeTarball, getRepoTreeId, listRepoDir, getPullRequest, getCollaboratorPermission, getAdvisory, listRepoAdvisories }
|
|
307
|
+
export const readMethods = { getCurrentUser, listUserRepos, getRepo, getRepoHead, getRepoTag, listRepoTags, getRepoFile, getRepoTarball, getRepoTreeTarball, getRepoTreeId, listRepoDir, findRepoLockfiles, getPullRequest, getCollaboratorPermission, getAdvisory, listRepoAdvisories }
|
|
223
308
|
export const createClient = (options) => bindMethods(clientHeaders('createClient', options, true), readMethods)
|
package/src/http.js
CHANGED
|
@@ -32,11 +32,13 @@ const LIMITS = {
|
|
|
32
32
|
}
|
|
33
33
|
const decoder = new TextDecoder('utf-8', { fatal: true })
|
|
34
34
|
|
|
35
|
+
// `headers` are the response's, where there was one to read them from.
|
|
35
36
|
export class HttpError extends Error {
|
|
36
|
-
constructor(status, message) {
|
|
37
|
+
constructor(status, message, headers) {
|
|
37
38
|
super(message)
|
|
38
39
|
this.name = 'HttpError'
|
|
39
40
|
this.status = status
|
|
41
|
+
if (headers !== undefined) this.headers = headers
|
|
40
42
|
}
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -119,7 +121,7 @@ export async function requestWithHeaders(url, options) {
|
|
|
119
121
|
if (!res.ok) {
|
|
120
122
|
// A stream decode holds back a character cut at the limit rather than refusing it.
|
|
121
123
|
const text = await readBody(res, 4096, { truncate: true }).then((bytes) => new TextDecoder('utf-8', { fatal: true }).decode(bytes, { stream: true })).catch(() => '')
|
|
122
|
-
throw new HttpError(res.status, `${options.method ?? 'GET'} ${url} ${res.status}: ${printable(text)}
|
|
124
|
+
throw new HttpError(res.status, `${options.method ?? 'GET'} ${url} ${res.status}: ${printable(text)}`, res.headers)
|
|
123
125
|
}
|
|
124
126
|
return { body: parse(await readBody(res, LIMITS[options.as].bytes), url, options.as), headers: res.headers }
|
|
125
127
|
}
|
package/src/lockfiles.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Each tool that writes a lockfile, by the format of it: the names it
|
|
2
|
+
// writes one under, and those of the project files it locks, each a name
|
|
3
|
+
// as git has it, `cargo.lock` not Cargo's, or a pattern. A lockfile's
|
|
4
|
+
// pattern names in a `locks` group the file beside it that it locks, where
|
|
5
|
+
// no name of that file's own tells it is one.
|
|
6
|
+
export const FORMATS = {
|
|
7
|
+
// pnpm-lock.<branch>.yaml with git-branch-lockfile; shrinkwrap.yaml before
|
|
8
|
+
// pnpm 3. package.yaml and package.json5 in place of package.json, and
|
|
9
|
+
// .pnpmfile's hooks, which change what is locked.
|
|
10
|
+
pnpm: {
|
|
11
|
+
lockfiles: ['pnpm-lock.yaml', /^pnpm-lock\..+\.yaml$/u, 'shrinkwrap.yaml'],
|
|
12
|
+
projects: ['package.json', 'package.yaml', 'package.json5', 'pnpm-workspace.yaml', '.pnpmfile.cjs', '.pnpmfile.mjs'],
|
|
13
|
+
},
|
|
14
|
+
// npm-shrinkwrap.json is npm's own format, which npm reads before a
|
|
15
|
+
// package-lock.json beside it.
|
|
16
|
+
npm: { lockfiles: ['package-lock.json', 'npm-shrinkwrap.json'], projects: ['package.json'] },
|
|
17
|
+
// Of Yarn 1 or a later Yarn, which only the text tells apart. .yarnrc.yml
|
|
18
|
+
// has Yarn 4's catalogs.
|
|
19
|
+
yarn: { lockfiles: ['yarn.lock'], projects: ['package.json', '.yarnrc.yml'] },
|
|
20
|
+
// bun.lockb is Bun's binary one, before Bun 1.2.
|
|
21
|
+
bun: { lockfiles: ['bun.lock', 'bun.lockb'], projects: ['package.json'] },
|
|
22
|
+
deno: { lockfiles: ['deno.lock'], projects: ['deno.json', 'deno.jsonc', 'package.json'] },
|
|
23
|
+
// <script>.py.lock, of `uv lock --script`, which locks the script.
|
|
24
|
+
uv: { lockfiles: ['uv.lock', /^(?<locks>.+\.py)\.lock$/u], projects: ['pyproject.toml'] },
|
|
25
|
+
poetry: { lockfiles: ['poetry.lock'], projects: ['pyproject.toml'] },
|
|
26
|
+
// PEP 751's.
|
|
27
|
+
pylock: { lockfiles: ['pylock.toml', /^pylock\.[^.]+\.toml$/u], projects: ['pyproject.toml'] },
|
|
28
|
+
pipenv: { lockfiles: ['Pipfile.lock'], projects: ['Pipfile'] },
|
|
29
|
+
pdm: { lockfiles: ['pdm.lock'], projects: ['pyproject.toml'] },
|
|
30
|
+
pixi: { lockfiles: ['pixi.lock'], projects: ['pixi.toml', 'pyproject.toml'] },
|
|
31
|
+
// A unified lockfile of another name has to end in `.conda-lock.yml`; the
|
|
32
|
+
// explicit and env ones are conda-<platform>.lock and .lock.yml. A
|
|
33
|
+
// conda-build recipe, meta.yaml, is a source too.
|
|
34
|
+
'conda-lock': {
|
|
35
|
+
lockfiles: ['conda-lock.yml', 'conda-lock.yaml', /^.+\.conda-lock\.yml$/u, /^conda-[\da-z]+-[\da-z]+\.lock(?:\.yml)?$/u],
|
|
36
|
+
projects: ['environment.yml', 'environment.yaml', 'meta.yaml', 'pyproject.toml'],
|
|
37
|
+
},
|
|
38
|
+
// A gemspec, whose dependencies a Gemfile's `gemspec` takes.
|
|
39
|
+
bundler: { lockfiles: ['Gemfile.lock', 'gems.locked'], projects: ['Gemfile', 'gems.rb', /^.+\.gemspec$/u] },
|
|
40
|
+
composer: { lockfiles: ['composer.lock'], projects: ['composer.json'] },
|
|
41
|
+
cargo: { lockfiles: ['Cargo.lock'], projects: ['Cargo.toml'] },
|
|
42
|
+
soldeer: { lockfiles: ['soldeer.lock'], projects: ['soldeer.toml', 'foundry.toml'] },
|
|
43
|
+
foundry: { lockfiles: ['foundry.lock'], projects: ['foundry.toml'] },
|
|
44
|
+
// Each name CocoaPods reads a Podfile under, and a podspec, whose
|
|
45
|
+
// dependencies a Podfile's `podspec` takes.
|
|
46
|
+
cocoapods: { lockfiles: ['Podfile.lock'], projects: ['Podfile', 'Podfile.rb', 'CocoaPods.podfile', 'CocoaPods.podfile.yaml', /^.+\.podspec(?:\.json)?$/u] },
|
|
47
|
+
swiftpm: { lockfiles: ['Package.resolved'], projects: ['Package.swift', /^Package@swift-\d+(?:\.\d+){0,2}\.swift$/u] },
|
|
48
|
+
carthage: { lockfiles: ['Cartfile.resolved'], projects: ['Cartfile', 'Cartfile.private'] },
|
|
49
|
+
// go.sum has the checksums; go.mod, the versions, is locked as it is. A
|
|
50
|
+
// workspace's go.work.sum has those its modules' go.sum do not.
|
|
51
|
+
go: { lockfiles: ['go.sum', 'go.work.sum'], projects: ['go.mod', 'go.work'] },
|
|
52
|
+
dep: { lockfiles: ['Gopkg.lock'], projects: ['Gopkg.toml'] },
|
|
53
|
+
// packages.<project>.lock.json, for one of projects that share a
|
|
54
|
+
// directory; Directory.Packages.props has the versions of Central
|
|
55
|
+
// Package Management, which the project files then leave out.
|
|
56
|
+
nuget: { lockfiles: ['packages.lock.json', /^packages\..+\.lock\.json$/u], projects: [/^.+\.(?:cs|fs|vb)proj$/u, 'Directory.Packages.props'] },
|
|
57
|
+
paket: { lockfiles: ['paket.lock'], projects: ['paket.dependencies'] },
|
|
58
|
+
// Any *.lockfile: Gradle's own, the legacy one of each configuration, or
|
|
59
|
+
// one a build names. Version catalogs, libs.versions.toml and the like.
|
|
60
|
+
gradle: { lockfiles: [/^.+\.lockfile$/u], projects: ['build.gradle', 'build.gradle.kts', 'settings.gradle', 'settings.gradle.kts', /^.+\.versions\.toml$/u] },
|
|
61
|
+
// pubspec_overrides.yaml overrides pubspec.yaml's dependency_overrides,
|
|
62
|
+
// workspace and resolution.
|
|
63
|
+
pub: { lockfiles: ['pubspec.lock'], projects: ['pubspec.yaml', 'pubspec_overrides.yaml'] },
|
|
64
|
+
mix: { lockfiles: ['mix.lock'], projects: ['mix.exs'] },
|
|
65
|
+
// rebar.config.script, where there is one, makes the configuration.
|
|
66
|
+
rebar: { lockfiles: ['rebar.lock'], projects: ['rebar.config', 'rebar.config.script'] },
|
|
67
|
+
julia: { lockfiles: ['Manifest.toml', 'JuliaManifest.toml', /^(?:Julia)?Manifest-v\d+\.\d+\.toml$/u], projects: ['Project.toml', 'JuliaProject.toml'] },
|
|
68
|
+
// Each of a configuration's name, stack.yaml or another, and `.lock`.
|
|
69
|
+
stack: { lockfiles: [/^(?<locks>.+\.ya?ml)\.lock$/u], projects: ['stack.yaml'] },
|
|
70
|
+
// Each of a project file's name, cabal.project or another, and `.freeze`.
|
|
71
|
+
cabal: { lockfiles: [/^(?<locks>.+)\.freeze$/u], projects: ['cabal.project', /^.+\.cabal$/u] },
|
|
72
|
+
shards: { lockfiles: ['shard.lock'], projects: ['shard.yml', 'shard.override.yml'] },
|
|
73
|
+
opam: { lockfiles: ['opam.locked', /^.+\.opam\.locked$/u], projects: ['opam', /^.+\.opam$/u] },
|
|
74
|
+
nix: { lockfiles: ['flake.lock'], projects: ['flake.nix'] },
|
|
75
|
+
conan: { lockfiles: ['conan.lock'], projects: ['conanfile.txt', 'conanfile.py'] },
|
|
76
|
+
// <name>.MODULE.bazel, which MODULE.bazel's include() takes.
|
|
77
|
+
bazel: { lockfiles: ['MODULE.bazel.lock'], projects: ['MODULE.bazel', /^[^.].*\.MODULE\.bazel$/u] },
|
|
78
|
+
helm: { lockfiles: ['Chart.lock'], projects: ['Chart.yaml'] },
|
|
79
|
+
// Of no project file, but the library it is made from.
|
|
80
|
+
renv: { lockfiles: ['renv.lock'], projects: [] },
|
|
81
|
+
// Of the configuration in the directory, each of its .tf files, a child
|
|
82
|
+
// module's too, of which there is none; OpenTofu's .tofu files, and a
|
|
83
|
+
// Stack's components.
|
|
84
|
+
terraform: { lockfiles: ['.terraform.lock.hcl'], projects: [/^.+\.(?:tf|tofu)(?:\.json)?$/u, /^.+\.tfcomponent\.hcl$/u] },
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const TOOLS = Object.entries(FORMATS)
|
|
88
|
+
const KNOWN = TOOLS.flatMap(([, { lockfiles, projects }]) => [...lockfiles, ...projects])
|
|
89
|
+
|
|
90
|
+
// A name of none of them, as most in a repository are, is passed over at
|
|
91
|
+
// once, by a set of the names and one pattern of all the patterns.
|
|
92
|
+
const NAMES = new Set(KNOWN.filter((known) => typeof known === 'string'))
|
|
93
|
+
const PATTERN = new RegExp(KNOWN.filter((known) => typeof known !== 'string').map(({ source }) => `(?:${source})`).join('|'), 'u')
|
|
94
|
+
const isKnown = (name) => NAMES.has(name) || PATTERN.test(name)
|
|
95
|
+
|
|
96
|
+
// Whether `name` is one of `names`, each a name or a pattern.
|
|
97
|
+
const isOf = (names, name) => names.some((known) => (typeof known === 'string' ? known === name : known.test(name)))
|
|
98
|
+
|
|
99
|
+
// The lockfile a file of this name is, `{ format, locks }`, `locks` the name
|
|
100
|
+
// of the file beside it that it locks where its pattern names one; or
|
|
101
|
+
// undefined.
|
|
102
|
+
export function lockfileOf(name) {
|
|
103
|
+
for (const [format, { lockfiles }] of isKnown(name) ? TOOLS : []) {
|
|
104
|
+
for (const known of lockfiles) {
|
|
105
|
+
const match = typeof known === 'string' ? known === name && {} : known.exec(name)
|
|
106
|
+
if (match) return { format, locks: match.groups?.locks }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The formats of the lockfiles that lock a project of a file of this name,
|
|
112
|
+
// in FORMATS' order, none where it is no project file.
|
|
113
|
+
export const projectFormats = (name) => (isKnown(name) ? TOOLS.filter(([, { projects }]) => isOf(projects, name)).map(([format]) => format) : [])
|