@preventive/upstream 1.0.0-alpha.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/LICENSE +21 -0
- package/advisories.d.ts +82 -0
- package/advisories.js +2 -0
- package/git.d.ts +21 -0
- package/git.js +1 -0
- package/github/write.d.ts +42 -0
- package/github/write.js +2 -0
- package/github.d.ts +68 -0
- package/github.js +2 -0
- package/npm.d.ts +89 -0
- package/npm.js +4 -0
- package/package.d.ts +18 -0
- package/package.js +1 -0
- package/package.json +95 -0
- package/semver.d.ts +11 -0
- package/semver.js +1 -0
- package/src/advisories/common.js +34 -0
- package/src/advisories/github.js +90 -0
- package/src/advisories/index.js +50 -0
- package/src/advisories/npm.js +58 -0
- package/src/advisories/osv.js +108 -0
- package/src/advisories/repos.js +72 -0
- package/src/args.js +72 -0
- package/src/cache.js +57 -0
- package/src/git.js +58 -0
- package/src/github/client.js +31 -0
- package/src/github/read.js +124 -0
- package/src/github/write.js +112 -0
- package/src/http.js +112 -0
- package/src/npm/repos.js +68 -0
- package/src/npm/tarball.js +115 -0
- package/src/package.js +49 -0
- package/src/pool.js +12 -0
- package/src/remote.js +15 -0
- package/src/semver.js +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nikita Skovoroda
|
|
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.
|
package/advisories.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Hand-written against advisories.js; a change to either belongs with the
|
|
2
|
+
// other.
|
|
3
|
+
|
|
4
|
+
import type { Client, RepoName } from './github.js'
|
|
5
|
+
|
|
6
|
+
export { HttpError } from './npm.js'
|
|
7
|
+
|
|
8
|
+
// As purl and stasis name them.
|
|
9
|
+
export type Ecosystem = 'npm' | 'cargo' | 'composer' | 'github'
|
|
10
|
+
|
|
11
|
+
export interface Package {
|
|
12
|
+
ecosystem: Ecosystem
|
|
13
|
+
// An npm package name, a crate name, a Composer `vendor/name`, or for
|
|
14
|
+
// `github`, the repository `owner/name` itself.
|
|
15
|
+
name: string
|
|
16
|
+
// Its GitHub repository, where the caller knows it (package.json,
|
|
17
|
+
// Cargo.toml, installed.json), for `repoAdvisories` to ask without
|
|
18
|
+
// looking it up; not for `github` packages, which are their own.
|
|
19
|
+
github?: RepoName
|
|
20
|
+
// npm and `github`: semver; `github` also takes a branch name or 0.0.0,
|
|
21
|
+
// which every range covers. cargo: semver, build metadata allowed.
|
|
22
|
+
// composer: a release (`v1.2.3`, `1.2.3.4`, `2.0.0-RC1`), dev versions
|
|
23
|
+
// refused; one semver cannot read is covered by every range.
|
|
24
|
+
versions: string[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AdvisoryOptions {
|
|
28
|
+
// The client every GitHub request goes through. Required for `github`
|
|
29
|
+
// packages, whose repository's published advisories are their only
|
|
30
|
+
// source, and for `repoAdvisories`; by itself it asks nothing else.
|
|
31
|
+
github?: Client
|
|
32
|
+
// Also asks each npm, cargo and composer package's GitHub repository
|
|
33
|
+
// for its published advisories, which it has before GitHub reviews them
|
|
34
|
+
// into the databases above: the package's `github` where given, else
|
|
35
|
+
// the one npm's metadata, crates.io or Packagist names, looked up
|
|
36
|
+
// through the cache. Needs `github`.
|
|
37
|
+
repoAdvisories?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// One advisory on one package, with the versions asked about that it
|
|
41
|
+
// covers (never empty).
|
|
42
|
+
export interface Advisory {
|
|
43
|
+
ecosystem: Ecosystem
|
|
44
|
+
name: string
|
|
45
|
+
// `registry`: npm's, as `npm audit` has it, one row per vulnerable
|
|
46
|
+
// range. `osv`: RustSec for cargo, OSV's Packagist records for composer.
|
|
47
|
+
// `repository`: published on the package's repository, one row per
|
|
48
|
+
// range, holding only the versions the others' answer does not report
|
|
49
|
+
// under that GHSA.
|
|
50
|
+
source: 'registry' | 'osv' | 'repository'
|
|
51
|
+
// A GHSA, RUSTSEC-…, DRUPAL-CORE-…, or npm:<id> for a registry row
|
|
52
|
+
// without a GHSA.
|
|
53
|
+
id: string
|
|
54
|
+
// The id itself, or an OSV record's one GHSA alias.
|
|
55
|
+
ghsa?: string
|
|
56
|
+
aliases: string[]
|
|
57
|
+
title?: string
|
|
58
|
+
// `critical`, `high`, `moderate`, `low`; GitHub's `medium` reads
|
|
59
|
+
// `moderate`. RustSec has none.
|
|
60
|
+
severity?: string
|
|
61
|
+
// Absent where there is no score (npm spells that 0).
|
|
62
|
+
cvss?: number
|
|
63
|
+
cvssVector?: string
|
|
64
|
+
cwe: string[]
|
|
65
|
+
// As written: npm's syntax from the registry, GitHub's
|
|
66
|
+
// (`>= 1.0.0, < 1.2.6`) from a repository; OSV rows have none. A
|
|
67
|
+
// repository range semver cannot read, or none, covers every version.
|
|
68
|
+
range?: string
|
|
69
|
+
// RustSec's kind for an advisory that is not a vulnerability as such:
|
|
70
|
+
// `unmaintained`, `unsound`, `notice`.
|
|
71
|
+
informational?: string
|
|
72
|
+
versions: string[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Every package, and every one of its versions, is checked against its
|
|
76
|
+
// ecosystem's rules before the first request; a name given twice is
|
|
77
|
+
// merged, and must not name two repositories. npm is asked 250 names a
|
|
78
|
+
// request, OSV 1000 versions; a repository gone, renamed or blocked adds
|
|
79
|
+
// nothing. Any other failure, a malformed answer, or one about something
|
|
80
|
+
// not asked, throws: nothing is left out quietly. Sorted by ecosystem and
|
|
81
|
+
// name. Versions are matched by npm's semver, from the npm beside node.
|
|
82
|
+
export function advisories(packages: Iterable<Package>, options?: AdvisoryOptions): Promise<Advisory[]>
|
package/advisories.js
ADDED
package/git.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Hand-written against git.js; a change to either belongs with the other.
|
|
2
|
+
|
|
3
|
+
// The shape getRepo (package.js) answers in, and HEAD's commit. Each
|
|
4
|
+
// field best-effort: left out where it cannot be read.
|
|
5
|
+
export interface GitCheckout {
|
|
6
|
+
// `owner/name`, when the origin remote is a GitHub one. Never the
|
|
7
|
+
// remote's URL, which can name a private host or carry a token.
|
|
8
|
+
github?: string
|
|
9
|
+
// Where the directory sits in its checkout, `/`-separated; absent at
|
|
10
|
+
// the root.
|
|
11
|
+
directory?: string
|
|
12
|
+
// `https://github.com/<github>`, whenever there is a `github`.
|
|
13
|
+
url?: string
|
|
14
|
+
// HEAD's commit sha.
|
|
15
|
+
commit?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The checkout at or above `dir`, found by its closest `.git` (a
|
|
19
|
+
// directory, or a `gitdir:` file) and read without running git. Empty
|
|
20
|
+
// where there is no checkout.
|
|
21
|
+
export function findGitCheckout(dir: string): Promise<GitCheckout>
|
package/git.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { findGitCheckout } from './src/git.js'
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Hand-written against github/write.js; a change to either belongs with
|
|
2
|
+
// the other. Arguments are checked as github.d.ts describes.
|
|
3
|
+
|
|
4
|
+
import type { Client, RepoName } from '../github.js'
|
|
5
|
+
|
|
6
|
+
export { HttpError } from '../github.js'
|
|
7
|
+
|
|
8
|
+
export interface WriteClientOptions {
|
|
9
|
+
// Never anonymous.
|
|
10
|
+
token: string
|
|
11
|
+
userAgent?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type CommitMessage = string | { headline: string; body?: string }
|
|
15
|
+
|
|
16
|
+
export interface WriteClient extends Client {
|
|
17
|
+
// Into the authenticated user's account, or the given organization.
|
|
18
|
+
// Answers GitHub's repo object; `full_name` names the fork.
|
|
19
|
+
forkRepo(options: { repo: RepoName; name?: string; organization?: string; defaultBranchOnly?: boolean }): Promise<any>
|
|
20
|
+
// At the full commit `oid`, or at the head of the default branch
|
|
21
|
+
// without one.
|
|
22
|
+
createBranch(options: { repo: RepoName; branch: string; oid?: string }): Promise<any>
|
|
23
|
+
// A signed commit on an existing branch, through GraphQL
|
|
24
|
+
// createCommitOnBranch. `expectedHeadOid` defaults to the branch head.
|
|
25
|
+
createCommit(options: {
|
|
26
|
+
repo: RepoName
|
|
27
|
+
branch: string
|
|
28
|
+
message: CommitMessage
|
|
29
|
+
additions?: { path: string; contents: string | Uint8Array }[]
|
|
30
|
+
deletions?: (string | { path: string })[]
|
|
31
|
+
expectedHeadOid?: string
|
|
32
|
+
}): Promise<{ oid: string; url: string }>
|
|
33
|
+
// `head` is `owner:branch` for a pull request from a fork.
|
|
34
|
+
createPR(options: { repo: RepoName; title: string; head: string; base: string; body?: string; draft?: boolean }): Promise<any>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createWriteClient(options: WriteClientOptions): WriteClient
|
|
38
|
+
|
|
39
|
+
// A GraphQL response's `data`, or a throw carrying the status and body on
|
|
40
|
+
// a transport error (an HttpError, as a REST failure is), malformed JSON
|
|
41
|
+
// or an `errors` array.
|
|
42
|
+
export function parseGraphQLResponse(status: number, text: string): any
|
package/github/write.js
ADDED
package/github.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Hand-written against github.js; a change to either belongs with the other.
|
|
2
|
+
//
|
|
3
|
+
// Every method checks its arguments hard before any request is built:
|
|
4
|
+
// an options object holding only the keys listed here, a repo that is
|
|
5
|
+
// `owner/name` by GitHub's rules, branches and tags that git would take,
|
|
6
|
+
// full commit shas, paths with no empty, `.` or `..` component, titles
|
|
7
|
+
// and headlines on one line. A bad one is a rejection naming the method;
|
|
8
|
+
// nothing is sent. Every URL is built from checked, encoded segments and
|
|
9
|
+
// has to come back out of URL parsing unchanged. Redirects are refused,
|
|
10
|
+
// except the tarball's, so an answer is about the repo asked for. A
|
|
11
|
+
// response is read up to a size limit (64 MiB of JSON, 128 MiB of a file,
|
|
12
|
+
// 512 MiB of a tarball) and within a timeout (30 seconds, 5 minutes for a
|
|
13
|
+
// tarball), and what it says goes into an error message escaped.
|
|
14
|
+
|
|
15
|
+
// `owner/name`.
|
|
16
|
+
export type RepoName = string
|
|
17
|
+
|
|
18
|
+
export interface ClientOptions {
|
|
19
|
+
// `null` for an anonymous client, which reads public repositories
|
|
20
|
+
// without anyone's credentials. Required either way.
|
|
21
|
+
token: string | null
|
|
22
|
+
userAgent?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type PullRequestStatus = 'open' | 'draft' | 'closed' | 'merged'
|
|
26
|
+
|
|
27
|
+
// A failed request: `status` is the HTTP status GitHub answered with.
|
|
28
|
+
export class HttpError extends Error {
|
|
29
|
+
name: 'HttpError'
|
|
30
|
+
status: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Reads only.
|
|
34
|
+
export interface Client {
|
|
35
|
+
// An advisory by its GHSA id. With `repo`, that repository's published
|
|
36
|
+
// copy: the maintainer's latest text, there before GitHub reviews it,
|
|
37
|
+
// and shaped as a repository advisory (`state`, `patched_versions`, no
|
|
38
|
+
// `type`). Without `repo`, or with a repository gone, renamed or blocked
|
|
39
|
+
// (301, 404, 410, 451), GitHub's global database, which has it only once
|
|
40
|
+
// reviewed; any other failure throws. Refused unless it is the one asked
|
|
41
|
+
// for, and published.
|
|
42
|
+
getAdvisory(options: { ghsa: string; repo?: RepoName }): Promise<{ ghsa_id: string } & Record<string, any>>
|
|
43
|
+
// GitHub's `{ permission, role_name, user }` for `username` on `repo`,
|
|
44
|
+
// teams, organization and enterprise grants included.
|
|
45
|
+
getCollaboratorPermission(options: { repo: RepoName; username: string }): Promise<{ permission: string; role_name?: string; user: { login: string; id: number } } & Record<string, any>>
|
|
46
|
+
getCurrentUser(): Promise<any>
|
|
47
|
+
// Refused unless GitHub answers with that pull request in that repo.
|
|
48
|
+
getPullRequest(options: { repo: RepoName; number: number }): Promise<{ title: string; status: PullRequestStatus }>
|
|
49
|
+
// GitHub's repository object, refused unless it is the repo asked for.
|
|
50
|
+
getRepo(options: { repo: RepoName }): Promise<any>
|
|
51
|
+
// A file's contents as text, at `ref` or the default branch. Refused for
|
|
52
|
+
// a directory, a symlink or a submodule, unless the bytes hash to the
|
|
53
|
+
// blob GitHub names for the path, and unless valid UTF-8.
|
|
54
|
+
getRepoFile(options: { repo: RepoName; path: string; ref?: string }): Promise<string>
|
|
55
|
+
// The head of `branch`, or of the default branch without one.
|
|
56
|
+
getRepoHead(options: { repo: RepoName; branch?: string }): Promise<{ branch: string; oid: string }>
|
|
57
|
+
// The repo's gzipped tarball at the full commit `sha`, whole, in memory.
|
|
58
|
+
getRepoTarball(options: { repo: RepoName; sha: string }): Promise<Uint8Array>
|
|
59
|
+
// The repository's published security advisories, as GitHub's
|
|
60
|
+
// repository advisory objects. One page of 100: GitHub pages this list
|
|
61
|
+
// by cursor, so a repository with a full page is refused.
|
|
62
|
+
listRepoAdvisories(options: { repo: RepoName }): Promise<any[]>
|
|
63
|
+
// Every page of `GET /user/repos`, as GitHub's repository objects; more
|
|
64
|
+
// than `maxPages` (100 by default) pages of 100 is an error.
|
|
65
|
+
listUserRepos(options?: { maxPages?: number }): Promise<any[]>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createClient(options: ClientOptions): Client
|
package/github.js
ADDED
package/npm.d.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Hand-written against npm.js; a change to either belongs with the other.
|
|
2
|
+
//
|
|
3
|
+
// Every call that reaches the registry first asserts that the name is a
|
|
4
|
+
// string of the shape npm takes, and a version a string that npm's
|
|
5
|
+
// semver.valid answers with unchanged; neither goes near a request, or a
|
|
6
|
+
// cache path, otherwise. Responses are read up to a size limit and within
|
|
7
|
+
// a timeout, as github.d.ts describes, and lookups for many names go to
|
|
8
|
+
// the registry eight at a time.
|
|
9
|
+
|
|
10
|
+
// Where cached answers live, resolved when set. Unset by default, and
|
|
11
|
+
// unset means no cache: every read misses and every write is skipped.
|
|
12
|
+
//
|
|
13
|
+
// The directory is trusted: a package's repo is answered from it as it
|
|
14
|
+
// was written, with no request. Point it only at storage that nothing
|
|
15
|
+
// less trusted than the caller can write, never a cache shared with or
|
|
16
|
+
// restored from lower-trust jobs. Tarballs don't rely on this, since they
|
|
17
|
+
// are checked against the registry's integrity on every call.
|
|
18
|
+
export function setCacheDir(dir: string): void
|
|
19
|
+
|
|
20
|
+
// A failed request: `status` is the HTTP status the registry answered
|
|
21
|
+
// with (404 for a package or version it does not have).
|
|
22
|
+
export class HttpError extends Error {
|
|
23
|
+
name: 'HttpError'
|
|
24
|
+
status: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A package's GitHub repo as its registry metadata names it — getRepo
|
|
28
|
+
// (package.js) over its `latest` document: `github` is `owner/name`, and
|
|
29
|
+
// `directory` is where in the repo the package sits, absent for a
|
|
30
|
+
// package at the repo root. Throws when the package cannot be fetched or
|
|
31
|
+
// names no GitHub repo.
|
|
32
|
+
export interface GitHubLink {
|
|
33
|
+
github: string
|
|
34
|
+
directory?: string
|
|
35
|
+
url: string
|
|
36
|
+
}
|
|
37
|
+
export function getGitHub(name: string): Promise<GitHubLink>
|
|
38
|
+
|
|
39
|
+
// What resolvePackageRepos maps each resolved name to.
|
|
40
|
+
export interface PackageRepo {
|
|
41
|
+
github: string
|
|
42
|
+
directory?: string
|
|
43
|
+
}
|
|
44
|
+
// Fail-soft: a name that resolves to no repo is absent from the map.
|
|
45
|
+
// `cachedOnly` answers from the disk cache alone and never fetches.
|
|
46
|
+
export function resolvePackageRepos(packageNames: Iterable<string>, options?: { cachedOnly?: boolean }): Promise<Map<string, PackageRepo>>
|
|
47
|
+
|
|
48
|
+
// The disk cache resolvePackageRepos reads and writes, a month per entry.
|
|
49
|
+
// A read answers null for anything but a usable entry; a write answers
|
|
50
|
+
// false where it could not write, and never throws.
|
|
51
|
+
export function readPackageRepoCache(name: string): Promise<PackageRepo | null>
|
|
52
|
+
export function writePackageRepoCache(name: string, github: string, directory?: string): Promise<boolean>
|
|
53
|
+
|
|
54
|
+
// Where a published version's tarball is and what it hashes to: exactly
|
|
55
|
+
// these two, `tarball` the registry's own URL for that version and
|
|
56
|
+
// `integrity` one `sha512-<base64>`, nothing else (no sha1, second hash
|
|
57
|
+
// or option). A dist given to a function below is held to the same.
|
|
58
|
+
export interface Dist {
|
|
59
|
+
tarball: string
|
|
60
|
+
integrity: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface PackageMeta {
|
|
64
|
+
name: string
|
|
65
|
+
version: string
|
|
66
|
+
dist: Dist
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The registry's version document, refused unless it is for that name and
|
|
70
|
+
// version and its dist is held to the rules above.
|
|
71
|
+
export function getMeta(name: string, version: string): Promise<PackageMeta>
|
|
72
|
+
|
|
73
|
+
// Fetches the registry's dist for that version and throws unless its
|
|
74
|
+
// integrity is the one given, as for a dist read off a lockfile.
|
|
75
|
+
export function verifyDist(name: string, version: string, dist: Dist): Promise<void>
|
|
76
|
+
|
|
77
|
+
// A published version's gzipped tarball, whole, in memory. Without `dist`,
|
|
78
|
+
// the version document is fetched on every call, cached tarball or not;
|
|
79
|
+
// with one, it is trusted as given and nothing but the tarball is asked
|
|
80
|
+
// for. Either way the bytes are checked against `dist.integrity`, whether
|
|
81
|
+
// they were downloaded (before they are cached) or read from a cache.
|
|
82
|
+
//
|
|
83
|
+
// Other tools' caches are read first, and never written: npm's (cacache
|
|
84
|
+
// under npm_config_cache, else npm's default, `~/.npm` outside Windows;
|
|
85
|
+
// and npm 4's `<name>/<version>/package.tgz` there), then
|
|
86
|
+
// `~/.audit/cache/tgz/<org>:<name>-<version>.tgz`. A file there that does
|
|
87
|
+
// not match is passed over. One in setCacheDir's cache that does not
|
|
88
|
+
// match throws.
|
|
89
|
+
export function getTarball(name: string, version: string, dist?: Dist): Promise<Uint8Array>
|
package/npm.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { setCacheDir } from './src/cache.js'
|
|
2
|
+
export { HttpError } from './src/http.js'
|
|
3
|
+
export { getGitHub, readPackageRepoCache, resolvePackageRepos, writePackageRepoCache } from './src/npm/repos.js'
|
|
4
|
+
export { getMeta, getTarball, verifyDist } from './src/npm/tarball.js'
|
package/package.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Hand-written against package.js; a change to either belongs with the other.
|
|
2
|
+
|
|
3
|
+
export interface PackageRepoLink {
|
|
4
|
+
// `owner/name`, by GitHub's own rules.
|
|
5
|
+
github?: string
|
|
6
|
+
// Where in that repo the package sits, `/`-separated, as the tree path
|
|
7
|
+
// itself (a homepage URL's decoded); absent at its root, and whenever
|
|
8
|
+
// `github` is.
|
|
9
|
+
directory?: string
|
|
10
|
+
// `https://github.com/<github>`, whenever there is a `github`.
|
|
11
|
+
url?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Read off `bugs`, `repository` and `homepage`, in that order, from a
|
|
15
|
+
// package.json or the registry's document for a version of one. Both
|
|
16
|
+
// fields absent where nothing in it names a GitHub repo; a throw only
|
|
17
|
+
// for something that is not an object at all.
|
|
18
|
+
export function getRepo(pkg: object): PackageRepoLink
|
package/package.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { getRepo } from './src/package.js'
|
package/package.json
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@preventive/upstream",
|
|
3
|
+
"version": "1.0.0-alpha.0",
|
|
4
|
+
"description": "Minimal npm registry and GitHub clients, and a git checkout reader: the calls we use, nothing more",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./advisories.js": {
|
|
8
|
+
"types": "./advisories.d.ts",
|
|
9
|
+
"default": "./advisories.js"
|
|
10
|
+
},
|
|
11
|
+
"./github.js": {
|
|
12
|
+
"types": "./github.d.ts",
|
|
13
|
+
"default": "./github.js"
|
|
14
|
+
},
|
|
15
|
+
"./github/write.js": {
|
|
16
|
+
"types": "./github/write.d.ts",
|
|
17
|
+
"default": "./github/write.js"
|
|
18
|
+
},
|
|
19
|
+
"./git.js": {
|
|
20
|
+
"types": "./git.d.ts",
|
|
21
|
+
"default": "./git.js"
|
|
22
|
+
},
|
|
23
|
+
"./npm.js": {
|
|
24
|
+
"types": "./npm.d.ts",
|
|
25
|
+
"default": "./npm.js"
|
|
26
|
+
},
|
|
27
|
+
"./package.js": {
|
|
28
|
+
"types": "./package.d.ts",
|
|
29
|
+
"default": "./package.js"
|
|
30
|
+
},
|
|
31
|
+
"./semver.js": {
|
|
32
|
+
"types": "./semver.d.ts",
|
|
33
|
+
"default": "./semver.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"advisories.d.ts",
|
|
38
|
+
"advisories.js",
|
|
39
|
+
"github.d.ts",
|
|
40
|
+
"github.js",
|
|
41
|
+
"git.d.ts",
|
|
42
|
+
"git.js",
|
|
43
|
+
"github/write.d.ts",
|
|
44
|
+
"github/write.js",
|
|
45
|
+
"npm.d.ts",
|
|
46
|
+
"npm.js",
|
|
47
|
+
"package.d.ts",
|
|
48
|
+
"package.js",
|
|
49
|
+
"semver.d.ts",
|
|
50
|
+
"semver.js",
|
|
51
|
+
"src/advisories/common.js",
|
|
52
|
+
"src/advisories/github.js",
|
|
53
|
+
"src/advisories/index.js",
|
|
54
|
+
"src/advisories/npm.js",
|
|
55
|
+
"src/advisories/osv.js",
|
|
56
|
+
"src/advisories/repos.js",
|
|
57
|
+
"src/args.js",
|
|
58
|
+
"src/cache.js",
|
|
59
|
+
"src/git.js",
|
|
60
|
+
"src/github/client.js",
|
|
61
|
+
"src/github/read.js",
|
|
62
|
+
"src/github/write.js",
|
|
63
|
+
"src/http.js",
|
|
64
|
+
"src/npm/repos.js",
|
|
65
|
+
"src/npm/tarball.js",
|
|
66
|
+
"src/package.js",
|
|
67
|
+
"src/pool.js",
|
|
68
|
+
"src/remote.js",
|
|
69
|
+
"src/semver.js"
|
|
70
|
+
],
|
|
71
|
+
"engines": {
|
|
72
|
+
"node": ">=24.14.0"
|
|
73
|
+
},
|
|
74
|
+
"scripts": {
|
|
75
|
+
"test": "node --test ./tests/*.test.js"
|
|
76
|
+
},
|
|
77
|
+
"license": "MIT",
|
|
78
|
+
"author": {
|
|
79
|
+
"name": "Nikita Skovoroda",
|
|
80
|
+
"email": "chalkerx@gmail.com",
|
|
81
|
+
"url": "https://github.com/ChALkeR"
|
|
82
|
+
},
|
|
83
|
+
"repository": {
|
|
84
|
+
"type": "git",
|
|
85
|
+
"url": "git+https://github.com/PreventiveMeasures/libraries.git",
|
|
86
|
+
"directory": "upstream"
|
|
87
|
+
},
|
|
88
|
+
"bugs": {
|
|
89
|
+
"url": "https://github.com/PreventiveMeasures/libraries/issues"
|
|
90
|
+
},
|
|
91
|
+
"homepage": "https://github.com/PreventiveMeasures/libraries/tree/main/upstream",
|
|
92
|
+
"publishConfig": {
|
|
93
|
+
"access": "public"
|
|
94
|
+
}
|
|
95
|
+
}
|
package/semver.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Hand-written against semver.js; a change to either belongs with the other.
|
|
2
|
+
|
|
3
|
+
// npm's own semver, borrowed from the npm beside node and passed through
|
|
4
|
+
// as it is. Without it, every call throws, except valid() on a plain
|
|
5
|
+
// MAJOR.MINOR.PATCH with no options.
|
|
6
|
+
export function satisfies(version: string, range: string, options?: { includePrerelease?: boolean; loose?: boolean }): boolean
|
|
7
|
+
export function compareVersions(a: string, b: string): -1 | 0 | 1
|
|
8
|
+
// The version as semver spells it, or null: `v1.2.3` answers `1.2.3`.
|
|
9
|
+
export function valid(version: unknown, options?: { loose?: boolean }): string | null
|
|
10
|
+
// A string that valid() answers unchanged.
|
|
11
|
+
export function isExactVersion(version: unknown): version is string
|
package/semver.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { compareVersions, isExactVersion, satisfies, valid } from './src/semver.js'
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { isStrings, matches } from '../args.js'
|
|
2
|
+
import { satisfies, validRange } from '../semver.js'
|
|
3
|
+
|
|
4
|
+
export const order = (a, b) => (a > b) - (a < b)
|
|
5
|
+
|
|
6
|
+
// As `npm audit` matches: a prerelease is in a range around it.
|
|
7
|
+
const SEMVER = { includePrerelease: true, loose: true }
|
|
8
|
+
|
|
9
|
+
export const inRange = (version, range) => satisfies(version, range, SEMVER)
|
|
10
|
+
|
|
11
|
+
// The asked versions a range `covers`. One semver cannot read covers them
|
|
12
|
+
// all: a missed advisory is worse than a spare one.
|
|
13
|
+
export const covered = (asked, range, covers = inRange) => (validRange(range, SEMVER) === null ? [...asked] : asked.filter((version) => covers(version, range)))
|
|
14
|
+
|
|
15
|
+
// Remote text, as long as it is well-formed; anything else is refused as
|
|
16
|
+
// malformed by whoever reads it.
|
|
17
|
+
export const isText = (value) => typeof value === 'string' && value.isWellFormed()
|
|
18
|
+
|
|
19
|
+
const SEVERITIES = new Set(['critical', 'high', 'moderate', 'low', 'info'])
|
|
20
|
+
const isCvssVector = matches(/^CVSS:[34]\.\d(?:\/[A-Z]{1,4}:[A-Z]{1,2})+$/u)
|
|
21
|
+
const isCwe = matches(/^CWE-\d{1,6}$/u)
|
|
22
|
+
|
|
23
|
+
// The fields every source may carry, kept only in the shape they are
|
|
24
|
+
// documented in: npm's severity words (GitHub's `medium` read `moderate`),
|
|
25
|
+
// a score in (0, 10] (npm spells "not scored" 0), a CVSS vector, CWE ids.
|
|
26
|
+
export function metrics({ severity, score, vector, cwe }) {
|
|
27
|
+
const word = typeof severity === 'string' ? severity.toLowerCase().replace(/^medium$/u, 'moderate') : undefined
|
|
28
|
+
return {
|
|
29
|
+
...(SEVERITIES.has(word) && { severity: word }),
|
|
30
|
+
...(typeof score === 'number' && score > 0 && score <= 10 && { cvss: score }),
|
|
31
|
+
...(isCvssVector(vector) && { cvssVector: vector }),
|
|
32
|
+
cwe: isStrings(cwe) ? cwe.filter(isCwe) : [],
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
|
|
3
|
+
import { assertRepo, assertion, isGhsa, isRefName, isStrings } from '../args.js'
|
|
4
|
+
import { isGone } from '../github/client.js'
|
|
5
|
+
import { pool } from '../pool.js'
|
|
6
|
+
import { valid } from '../semver.js'
|
|
7
|
+
import { covered, inRange, isText, metrics } from './common.js'
|
|
8
|
+
|
|
9
|
+
const REPOS_AT_ONCE = 4
|
|
10
|
+
|
|
11
|
+
const isRepoAdvisory = (advisory) => advisory && typeof advisory === 'object' && isGhsa(advisory.ghsa_id)
|
|
12
|
+
&& advisory.state === 'published' && isText(advisory.summary)
|
|
13
|
+
&& (advisory.vulnerabilities == null || Array.isArray(advisory.vulnerabilities)) && (advisory.cwe_ids == null || isStrings(advisory.cwe_ids))
|
|
14
|
+
|
|
15
|
+
export const assertClient = assertion('a GitHub client from createClient', (value) => typeof value?.listRepoAdvisories === 'function')
|
|
16
|
+
|
|
17
|
+
const listAdvisories = (github, repo) => github.listRepoAdvisories({ repo }).catch((err) => {
|
|
18
|
+
if (isGone(err)) return []
|
|
19
|
+
throw err
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
// GitHub's `>= 1.0.0, < 1.2.6` is npm's with the commas dropped.
|
|
23
|
+
// Maintainers write these unreviewed: one without a range, or with one
|
|
24
|
+
// semver cannot read, covers every version.
|
|
25
|
+
function fromRepository(name, advisory, range, asked, covers) {
|
|
26
|
+
const cvss = [advisory.cvss_severities?.cvss_v3, advisory.cvss_severities?.cvss_v4, advisory.cvss].find((entry) => typeof entry?.vector_string === 'string')
|
|
27
|
+
return {
|
|
28
|
+
name,
|
|
29
|
+
source: 'repository',
|
|
30
|
+
id: advisory.ghsa_id,
|
|
31
|
+
ghsa: advisory.ghsa_id,
|
|
32
|
+
aliases: [],
|
|
33
|
+
title: advisory.summary,
|
|
34
|
+
...metrics({ severity: advisory.severity, score: cvss?.score, vector: cvss?.vector_string, cwe: advisory.cwe_ids }),
|
|
35
|
+
range,
|
|
36
|
+
versions: covered(asked, range.replaceAll(',', ' '), covers),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Each asked name's repository, `repoOf` it, is asked once, and its
|
|
41
|
+
// advisories' vulnerable ranges become rows for the names that `takes` a
|
|
42
|
+
// vulnerability, one per name, advisory and range, holding the asked
|
|
43
|
+
// versions it `covers`.
|
|
44
|
+
async function repositoryAdvisories(github, asked, { repoOf, takes = () => true, covers }) {
|
|
45
|
+
// GitHub's names are case-insensitive: one spelling asks for all.
|
|
46
|
+
const namesOf = Map.groupBy([...asked.keys()].filter(repoOf), (name) => repoOf(name).toLowerCase())
|
|
47
|
+
const listed = await pool([...namesOf.values()], REPOS_AT_ONCE, async (names) => ({ repo: repoOf(names[0]), names, list: await listAdvisories(github, repoOf(names[0])) }))
|
|
48
|
+
const rows = new Map()
|
|
49
|
+
for (const { repo, names, list } of listed) {
|
|
50
|
+
for (const advisory of list) {
|
|
51
|
+
assert.ok(isRepoAdvisory(advisory), `advisories: malformed advisory from ${repo}`)
|
|
52
|
+
for (const vulnerability of advisory.withdrawn_at ? [] : advisory.vulnerabilities ?? []) {
|
|
53
|
+
const range = vulnerability?.vulnerable_version_range ?? ''
|
|
54
|
+
assert.ok(typeof range === 'string', `advisories: malformed range in ${advisory.ghsa_id}`)
|
|
55
|
+
for (const name of names.filter((candidate) => takes(candidate, vulnerability?.package))) {
|
|
56
|
+
rows.set(`${name} ${advisory.ghsa_id} ${range}`, fromRepository(name, advisory, range, asked.get(name), covers))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return [...rows.values()]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A maintainer's advisory is on the repository before GitHub reviews it
|
|
65
|
+
// into the databases the registries answer from. With `repoAdvisories`,
|
|
66
|
+
// each package's repository, `known` or else looked up, adds what it
|
|
67
|
+
// publishes for GitHub's `ecosystem` entries naming the package, for the
|
|
68
|
+
// versions `rows` do not already report under that GHSA.
|
|
69
|
+
export async function withRepositories(rows, asked, { github, repoAdvisories, known }, { ecosystem, lookUp, covers }) {
|
|
70
|
+
if (!repoAdvisories) return rows
|
|
71
|
+
const reported = new Set(rows.flatMap((row) => [row.id, row.ghsa, ...row.aliases].filter(isGhsa).flatMap((id) => row.versions.map((version) => `${row.name} ${id} ${version}`))))
|
|
72
|
+
const found = await lookUp([...asked.keys()].filter((name) => !known.has(name)))
|
|
73
|
+
const repoOf = (name) => known.get(name) ?? found.get(name)
|
|
74
|
+
const takes = (name, pkg) => pkg?.ecosystem === ecosystem && pkg.name === name
|
|
75
|
+
const added = await repositoryAdvisories(github, asked, { repoOf, takes, covers })
|
|
76
|
+
return [...rows, ...added.map((row) => ({ ...row, versions: row.versions.filter((version) => !reported.has(`${row.name} ${row.id} ${version}`)) }))]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// stasis versions a repository with no version of its own by its branch,
|
|
80
|
+
// or 0.0.0: every range covers those. Whatever semver reads is a version,
|
|
81
|
+
// `v1.2.3` and `1.2.3+build` included.
|
|
82
|
+
const coversPlaceholder = (version, range) => version === '0.0.0' || valid(version) === null || inRange(version, range)
|
|
83
|
+
|
|
84
|
+
// Dependencies that are GitHub repositories themselves: every range their
|
|
85
|
+
// own published advisories list counts, whichever package it names.
|
|
86
|
+
export const GITHUB = {
|
|
87
|
+
assertName: assertRepo,
|
|
88
|
+
assertVersion: assertion('a version or a branch name', isRefName),
|
|
89
|
+
advisories: (asked, { github }) => repositoryAdvisories(github, asked, { repoOf: (repo) => repo, covers: coversPlaceholder }),
|
|
90
|
+
}
|