@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 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.
@@ -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
@@ -0,0 +1,2 @@
1
+ export { HttpError } from './src/http.js'
2
+ export { advisories } from './src/advisories/index.js'
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
@@ -0,0 +1,2 @@
1
+ export { createWriteClient, parseGraphQLResponse } from '../src/github/write.js'
2
+ export { HttpError } from '../src/http.js'
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
@@ -0,0 +1,2 @@
1
+ export { createClient } from './src/github/read.js'
2
+ export { HttpError } from './src/http.js'
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
+ }