@preventive/triage 1.0.0-alpha.44 → 1.0.0-alpha.45
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/common/bundle-sources.js +20 -1
- package/common/default-scan-models.ts +1 -1
- package/common/dependency-reviews.d.ts +0 -5
- package/common/dependency-reviews.js +0 -17
- package/common/managed/agent-tasks.ts +292 -0
- package/common/managed/github-agents.ts +92 -0
- package/common/managed/github-logins.ts +49 -0
- package/common/managed/roles.ts +7 -0
- package/common/managed/routes.js +88 -7
- package/common/managed/team-app.d.ts +5 -2
- package/common/managed/team-app.js +2 -2
- package/common/server-info.ts +3 -0
- package/common/workspace-app.js +6 -3
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2XUEZHIP.js +18 -18
- package/out/chunk-2ZDZWLFY.js +1 -0
- package/out/chunk-3PS4KWD2.js +10 -0
- package/out/chunk-5DHL5NG3.js +2 -0
- package/out/chunk-6HNXNIQY.js +52 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-AFLZH7WG.js +1 -0
- package/out/chunk-BC2UCLFJ.js +1 -0
- package/out/chunk-C4Z6ZBXV.js +6 -6
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-HMAJDVXN.js +1 -0
- package/out/chunk-I2MDGSQX.js +3 -0
- package/out/chunk-IYPY47MC.js +1 -0
- package/out/chunk-IZ7POWI3.js +18 -0
- package/out/chunk-JCIIBLKR.js +1 -1
- package/out/chunk-NPW52BM2.js +17 -0
- package/out/chunk-NRPC74AM.js +1 -1
- package/out/chunk-NV7ES4EW.js +1 -0
- package/out/chunk-OKRLLE5I.js +17 -0
- package/out/chunk-OOJM4CTU.js +1 -1
- package/out/chunk-PNHBZGDD.js +1 -1
- package/out/chunk-PTGDYLNS.js +36 -0
- package/out/chunk-PXK7EU4V.js +1 -0
- package/out/chunk-Q6MVYGYV.js +3 -0
- package/out/chunk-QB4CBOX2.js +1 -0
- package/out/chunk-QRD6GIG5.js +229 -0
- package/out/chunk-R3NZFXVR.js +1 -0
- package/out/chunk-R5O6FAW4.js +1 -1
- package/out/chunk-SXV2T5C4.js +80 -0
- package/out/chunk-TUII34K2.js +1 -1
- package/out/chunk-VFWMQ3LG.js +2 -0
- package/out/chunk-VH4XMZIW.js +2 -0
- package/out/chunk-WFYLMF6Z.js +10 -0
- package/out/chunk-WI7Z5A2T.js +6 -0
- package/out/chunk-WQ74ZBZF.js +7 -0
- package/out/chunk-XVBEOGOS.js +3 -0
- package/out/client-managed-import.js +3 -3
- package/out/client-managed.js +31 -35
- package/out/client-sync.js +11 -11
- package/out/compare.js +35 -0
- package/out/dialogs.js +51 -0
- package/out/github-issue-authorized.html +1 -1
- package/out/github-issue-authorized.js +1 -1
- package/out/graph.js +17 -17
- package/out/index.html +30 -34
- package/out/prism.js +14 -14
- package/out/scan.js +2 -0
- package/out/terminal.js +294 -293
- package/out/view.css +1 -1
- package/out/view.js +16 -107
- package/package.json +51 -6
- package/risk/actions/findings.js +156 -0
- package/risk/actions/resolve.js +219 -0
- package/risk/actions/scripts/commands.js +182 -0
- package/risk/actions/scripts/grade.js +113 -0
- package/risk/actions/scripts/packages.js +123 -0
- package/risk/actions/scripts/shell.js +165 -0
- package/risk/actions/uses.js +76 -0
- package/risk/actions/workflow.js +110 -0
- package/risk/files/hosts.js +6 -6
- package/risk/files/readability.js +23 -17
- package/risk/files/runs-code.js +18 -6
- package/risk/files/script.js +7 -3
- package/risk/files/stamps.js +8 -7
- package/risk/index.d.ts +68 -1
- package/risk/index.js +6 -2
- package/risk/npm/assess.js +1 -1
- package/risk/npm/compare.js +34 -10
- package/risk/npm/files.js +413 -2
- package/risk/npm/tarball.js +56 -73
- package/server-managed/agent-merges.ts +198 -0
- package/server-managed/app-agents.ts +30 -0
- package/server-managed/avatar-store.ts +1 -0
- package/server-managed/commit-coauthors.ts +83 -0
- package/server-managed/commit-lockfiles.ts +62 -0
- package/server-managed/db-methods.ts +29 -10
- package/server-managed/db-neon.ts +30 -2
- package/server-managed/db-schema.ts +9 -2
- package/server-managed/db.ts +7 -0
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +97 -0
- package/server-managed/dependency-reviews.ts +65 -15
- package/server-managed/github-accounts.ts +33 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +23 -9
- package/server-managed/github-errors.ts +2 -0
- package/server-managed/github-link-oauth.ts +69 -0
- package/server-managed/github-links.ts +204 -0
- package/server-managed/github-oauth.ts +5 -2
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http.ts +1122 -52
- package/server-managed/index.ts +6 -2
- package/server-managed/locked-packages.ts +37 -16
- package/server-managed/lockfile-scope.ts +49 -0
- package/server-managed/npm-delta-worker.js +23 -0
- package/server-managed/npm-delta.ts +173 -0
- package/server-managed/npm-insights.ts +20 -11
- package/server-managed/npm-kept.ts +51 -4
- package/server-managed/npm-loads.ts +20 -13
- package/server-managed/npm-packages.ts +24 -64
- package/server-managed/pull-comment-reviews.ts +161 -0
- package/server-managed/pull-dependencies.ts +280 -0
- package/server-managed/pull-dependency-reviews.ts +318 -0
- package/server-managed/pull-reviews.ts +205 -0
- package/server-managed/release-commits.ts +84 -0
- package/server-managed/repository-actions.ts +209 -0
- package/server-managed/repository-agents.ts +220 -0
- package/server-managed/repository-browser.ts +54 -13
- package/server-managed/repository-changes.ts +284 -0
- package/server-managed/repository-code.ts +52 -0
- package/server-managed/repository-dependency-reviews.ts +135 -0
- package/server-managed/repository-history.ts +445 -0
- package/server-managed/repository-issues.ts +181 -0
- package/server-managed/repository-pulse.ts +432 -0
- package/server-managed/repository-risk-read.ts +165 -0
- package/server-managed/repository-risk.ts +113 -105
- package/server-managed/reviewed-dependencies.ts +149 -0
- package/server-managed/session.ts +9 -5
- package/server-managed/sql.ts +1 -1
- package/server-managed/storage-stores.ts +5 -0
- package/server-managed/team-app.ts +19 -3
- package/server-managed/workspace-shares.ts +4 -1
- package/server.ts +11 -0
- package/out/chunk-2ABLKCOY.js +0 -46
- package/out/chunk-2KBF5TZ7.js +0 -2
- package/out/chunk-35VE6CDH.js +0 -2
- package/out/chunk-3BZ7JERB.js +0 -1
- package/out/chunk-4XCDFGDP.js +0 -10
- package/out/chunk-5LSXOLUS.js +0 -1
- package/out/chunk-5RCM2J63.js +0 -1
- package/out/chunk-7B6HB4D7.js +0 -1
- package/out/chunk-AJ4GTATZ.js +0 -3
- package/out/chunk-CH4UN6WK.js +0 -225
- package/out/chunk-CMQA4DM6.js +0 -1
- package/out/chunk-CVVJAXVY.js +0 -2
- package/out/chunk-DIJJNTRH.js +0 -10
- package/out/chunk-EDKLBQQN.js +0 -17
- package/out/chunk-F47DXCEV.js +0 -33
- package/out/chunk-FK3DZEKL.js +0 -1
- package/out/chunk-G6AJWENO.js +0 -1
- package/out/chunk-GJ4IUCNS.js +0 -1
- package/out/chunk-GNXOKIGP.js +0 -80
- package/out/chunk-GZ4MS3TE.js +0 -13
- package/out/chunk-GZWM7UQM.js +0 -2
- package/out/chunk-IU3CCINR.js +0 -3
- package/out/chunk-K3X5EJBQ.js +0 -2
- package/out/chunk-KEX2ATEW.js +0 -2
- package/out/chunk-KYCNJVB7.js +0 -1
- package/out/chunk-LPCTJYNA.js +0 -7
- package/out/chunk-O4RJY5TP.js +0 -2
- package/out/chunk-OD4LPN3H.js +0 -1
- package/out/chunk-QWDWTBM7.js +0 -1
- package/out/chunk-QYWKC557.js +0 -1
- package/out/chunk-R75XOKUP.js +0 -1
- package/out/chunk-SWTLADW5.js +0 -1
- package/out/chunk-T7OKH4VW.js +0 -1
- package/out/chunk-TZ5KB2Y5.js +0 -10
- package/out/chunk-UNTRGE26.js +0 -2
- package/out/chunk-UTR2LVME.js +0 -2
- package/out/chunk-UYAXVRVL.js +0 -1
- package/out/chunk-V3ME6FI5.js +0 -2
- package/out/chunk-V5O7M2YT.js +0 -1
- package/out/chunk-VIHGO77Y.js +0 -1
- package/out/chunk-W4J3MGLB.js +0 -1
- package/out/chunk-WGG7KQUH.js +0 -1
- package/out/chunk-XYFXXB2G.js +0 -10
- package/out/chunk-ZESLQCTF.js +0 -6
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// A repository's Dependabot alerts for a repository risk scan, read with the
|
|
2
|
+
// App's installation token where the admin's own access lets them see them on
|
|
3
|
+
// GitHub, as the README's repository risk section tells.
|
|
4
|
+
import type { ManagedConfig } from './config.ts'
|
|
5
|
+
import type { SelectedRepo } from './db.ts'
|
|
6
|
+
import { type DependabotAlert, dependabotAlerts } from './dependabot-alerts.ts'
|
|
7
|
+
import { GithubApiError, type GithubIdentity, githubRepoPermission, repoAccessToken, repositoryInstallation } from './github-app.ts'
|
|
8
|
+
|
|
9
|
+
// The levels GitHub lets see a repository's Dependabot alerts by default
|
|
10
|
+
// (maintain's reads `write`).
|
|
11
|
+
const SEES_ALERTS: ReadonlySet<string> = new Set(['admin', 'write'])
|
|
12
|
+
// Failures of verifying the sign-in that are the sign-in's own: no token, or
|
|
13
|
+
// one of another account (repository-browser.ts getIdentity), or one GitHub no
|
|
14
|
+
// longer takes. Any other, as GitHub failing to answer, is told as it is.
|
|
15
|
+
const UNVERIFIED: ReadonlySet<string> = new Set(['no-repository', 'github-unauthorized'])
|
|
16
|
+
|
|
17
|
+
// What of a repository's reader (repository-browser.ts) the alerts are asked
|
|
18
|
+
// with: the user's verified identity, and their permission level where its
|
|
19
|
+
// access gate asked GitHub of it already.
|
|
20
|
+
export interface AlertsReader {
|
|
21
|
+
identity(): Promise<GithubIdentity>
|
|
22
|
+
permission(): string | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Alerts read, and their `recheck`: the user's access to them checked again,
|
|
26
|
+
// asked of GitHub afresh, just before they are told (repository-risk.ts), as
|
|
27
|
+
// access to the repository is, as it may be withdrawn meanwhile.
|
|
28
|
+
export interface AlertsRead { alerts: DependabotAlert[]; recheck(): Promise<void> }
|
|
29
|
+
|
|
30
|
+
// `repo`'s Dependabot alerts (dependabot-alerts.ts), read with the token of
|
|
31
|
+
// the App's installation GitHub tells covers it now, its token checked to read
|
|
32
|
+
// it (github-app.ts repositoryInstallation), never the one its connection
|
|
33
|
+
// keeps, which may no longer cover it, once the user's own permission on it
|
|
34
|
+
// lets them see them; else refused, the alerts not asked:
|
|
35
|
+
// `dependabot-not-installed`, `dependabot-unverified`, or
|
|
36
|
+
// `dependabot-forbidden`, naming their permission. The token is asked each
|
|
37
|
+
// time of repoAccessToken, which renews it before it expires, as the recheck
|
|
38
|
+
// may come long after the alerts are read.
|
|
39
|
+
export async function appDependabotAlerts(config: ManagedConfig, repo: Pick<SelectedRepo, 'repoId' | 'fullName'>, reader: AlertsReader,
|
|
40
|
+
fetchImpl: typeof fetch = globalThis.fetch): Promise<AlertsRead> {
|
|
41
|
+
const id = await repositoryInstallation(config, repo.repoId, repo.fullName, fetchImpl)
|
|
42
|
+
const tokenNow = async () => {
|
|
43
|
+
const token = await repoAccessToken(config, id, fetchImpl)
|
|
44
|
+
if (token == null) throw new GithubApiError(403, 'dependabot-not-installed')
|
|
45
|
+
return token
|
|
46
|
+
}
|
|
47
|
+
const token = await tokenNow()
|
|
48
|
+
let who
|
|
49
|
+
try { who = await reader.identity() } catch (error) {
|
|
50
|
+
throw error instanceof GithubApiError && UNVERIFIED.has(error.message) ? new GithubApiError(403, 'dependabot-unverified') : error
|
|
51
|
+
}
|
|
52
|
+
const seen = (permission: string) => {
|
|
53
|
+
if (!SEES_ALERTS.has(permission)) {
|
|
54
|
+
throw new GithubApiError(403, 'dependabot-forbidden', null, `@${who.login}'s access to this repository (${permission}) doesn't let them see its Dependabot alerts on GitHub`)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
seen(reader.permission() ?? await githubRepoPermission(repo.fullName, token, who, fetchImpl))
|
|
58
|
+
const alerts = await dependabotAlerts(repo.fullName, token, fetchImpl)
|
|
59
|
+
return { alerts, recheck: async () => { seen(await githubRepoPermission(repo.fullName, await tokenNow(), who, fetchImpl)) } }
|
|
60
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// A repository's Dependabot alerts of npm's ecosystem, open or dismissed, as a
|
|
2
|
+
// repository risk scan blends them into npm's advisories (repository-risk.ts),
|
|
3
|
+
// a dismissed one with who dismissed it, when and why: GitHub's
|
|
4
|
+
// `GET /repos/:repo/dependabot/alerts`, a page of 100 at a time, each found by
|
|
5
|
+
// the cursor the one before's Link header names, MAX_PAGES at most. Asked with
|
|
6
|
+
// the App's installation token, once the admin's own access to the repository
|
|
7
|
+
// is found to let them see the alerts on GitHub (dependabot-access.ts): where
|
|
8
|
+
// the installation hasn't Dependabot alerts: read, or the repository has them
|
|
9
|
+
// off, that is thrown as its code, never GitHub's text, for the scan to tell
|
|
10
|
+
// and go on.
|
|
11
|
+
import { isExactVersion, satisfies, validRange } from '@preventive/upstream/semver.js'
|
|
12
|
+
import { isNpmPackageName } from '../common/managed/npm-packages.js'
|
|
13
|
+
import { GithubApiError, githubUnreachable, throwGithubResponseError } from './github-errors.ts'
|
|
14
|
+
import type { LockedPackage } from './locked-packages.ts'
|
|
15
|
+
|
|
16
|
+
// An alert, as GitHub has it: its `number` in its repository and its page
|
|
17
|
+
// there (`url`, built, never fetched), the manifest or lockfile it is of
|
|
18
|
+
// (`manifest`, from the repository's root), its package and advisory, the
|
|
19
|
+
// versions it affects as GitHub writes them (`range`, `>= 1.0.0, < 1.2.6`)
|
|
20
|
+
// and the first that isn't (`patched`), `dev` where GitHub tells the
|
|
21
|
+
// dependency is only for development, and `dismissed` where it is: by whom,
|
|
22
|
+
// when, why and what they said, or by one of the repository's rules (`auto`).
|
|
23
|
+
export interface DependabotAlert {
|
|
24
|
+
number: number
|
|
25
|
+
url: string
|
|
26
|
+
manifest: string
|
|
27
|
+
name: string
|
|
28
|
+
ghsa: string
|
|
29
|
+
// The advisory's other ids, as its CVE.
|
|
30
|
+
aliases: string[]
|
|
31
|
+
title?: string
|
|
32
|
+
// `critical`, `high`, `moderate`, `low`: GitHub's `medium` reads `moderate`.
|
|
33
|
+
severity?: string
|
|
34
|
+
range?: string
|
|
35
|
+
patched?: string
|
|
36
|
+
cwe: string[]
|
|
37
|
+
dev?: true
|
|
38
|
+
dismissed?: AlertDismissal
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// GitHub's `dismissed_reason`: `fix_started`, `inaccurate`, `no_bandwidth`,
|
|
42
|
+
// `not_used` or `tolerable_risk`; `at` as GitHub writes it (ISO 8601).
|
|
43
|
+
export interface AlertDismissal { auto?: true; reason?: string; at?: string; by?: string; comment?: string }
|
|
44
|
+
|
|
45
|
+
const API = 'https://api.github.com'
|
|
46
|
+
const API_VERSION = '2022-11-28'
|
|
47
|
+
const USER_AGENT = 'deepview-triage'
|
|
48
|
+
const PER_PAGE = 100
|
|
49
|
+
// Up to how many pages a listing asks: 2,000 alerts of npm's in a repository,
|
|
50
|
+
// past which the rest are left out; where open and dismissed ones are more,
|
|
51
|
+
// the open ones are listed again on their own (dependabotAlerts).
|
|
52
|
+
const MAX_PAGES = 20
|
|
53
|
+
// A page holds each advisory's whole text: given longer than githubJson's.
|
|
54
|
+
const PAGE_TIMEOUT_MS = 20_000
|
|
55
|
+
const GHSA = /^GHSA(?:-[23456789cfghjmpqrvwx]{4}){3}$/u
|
|
56
|
+
const SEVERITIES = new Map([['critical', 'critical'], ['high', 'high'], ['medium', 'moderate'], ['moderate', 'moderate'], ['low', 'low']])
|
|
57
|
+
// A path from a repository's root, as git takes one: no empty, `.` or `..`
|
|
58
|
+
// component, nor a control character.
|
|
59
|
+
const isRepoPath = (path: unknown): path is string => typeof path === 'string' && path.length <= 1024 && !/\p{Cc}/u.test(path)
|
|
60
|
+
&& path.split('/').every(part => part !== '' && part !== '.' && part !== '..')
|
|
61
|
+
|
|
62
|
+
// The states asked: a fixed alert's dependency is no longer what it was.
|
|
63
|
+
const STATES = ['open', 'dismissed', 'auto_dismissed']
|
|
64
|
+
const REASONS = new Set(['fix_started', 'inaccurate', 'no_bandwidth', 'not_used', 'tolerable_risk'])
|
|
65
|
+
const LOGIN = /^[\dA-Za-z](?:[\dA-Za-z-]{0,38})(?:\[bot\])?$/u
|
|
66
|
+
|
|
67
|
+
const text = (value: unknown, most = 500) => typeof value === 'string' && value.trim() ? value.trim().slice(0, most) : undefined
|
|
68
|
+
|
|
69
|
+
// How a dismissed alert was dismissed, as far as GitHub tells it soundly.
|
|
70
|
+
function dismissalOf(state: string, raw: Record<string, any>): AlertDismissal {
|
|
71
|
+
const at = state === 'auto_dismissed' ? raw['auto_dismissed_at'] : raw['dismissed_at']
|
|
72
|
+
const when = typeof at === 'string' && at.length <= 40 && !Number.isNaN(Date.parse(at)) ? { at } : {}
|
|
73
|
+
if (state === 'auto_dismissed') return { auto: true, ...when }
|
|
74
|
+
const by = raw['dismissed_by']?.login, comment = text(raw['dismissed_comment'], 280), reason = raw['dismissed_reason']
|
|
75
|
+
return { ...REASONS.has(reason) ? { reason } : {}, ...when, ...typeof by === 'string' && LOGIN.test(by) ? { by } : {}, ...comment === undefined ? {} : { comment } }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// An alert as GitHub answers it, or null for one that isn't an open or
|
|
79
|
+
// dismissed one of npm's, of a package and GHSA npm and GitHub would name.
|
|
80
|
+
function alertOf(fullName: string, raw: unknown): DependabotAlert | null {
|
|
81
|
+
if (raw == null || typeof raw !== 'object') return null
|
|
82
|
+
const { number, state, dependency, security_advisory: advisory, security_vulnerability: vulnerability } = raw as Record<string, any>
|
|
83
|
+
if (!Number.isSafeInteger(number) || number <= 0 || !STATES.includes(state)) return null
|
|
84
|
+
const ghsa = advisory?.ghsa_id, manifest = dependency?.manifest_path, name = dependency?.package?.name
|
|
85
|
+
if (dependency?.package?.ecosystem !== 'npm' || !isNpmPackageName(name) || !isRepoPath(manifest) || typeof ghsa !== 'string' || !GHSA.test(ghsa)) return null
|
|
86
|
+
const severity = SEVERITIES.get(vulnerability?.severity ?? advisory?.severity)
|
|
87
|
+
const patched = text(vulnerability?.first_patched_version?.identifier, 100), range = text(vulnerability?.vulnerable_version_range, 200), title = text(advisory?.summary)
|
|
88
|
+
const ids = Array.isArray(advisory?.identifiers) ? advisory.identifiers as { value?: unknown }[] : []
|
|
89
|
+
const cwes = Array.isArray(advisory?.cwes) ? advisory.cwes as { cwe_id?: unknown }[] : []
|
|
90
|
+
return {
|
|
91
|
+
number, url: `https://github.com/${fullName.split('/').map(encodeURIComponent).join('/')}/security/dependabot/${number}`, manifest, name, ghsa,
|
|
92
|
+
aliases: [...new Set(ids.flatMap(id => typeof id?.value === 'string' && /^[\w.:-]{1,64}$/u.test(id.value) && id.value !== ghsa ? [id.value] : []))],
|
|
93
|
+
...title === undefined ? {} : { title }, ...severity === undefined ? {} : { severity },
|
|
94
|
+
...range === undefined ? {} : { range }, ...patched === undefined ? {} : { patched },
|
|
95
|
+
cwe: [...new Set(cwes.flatMap(cwe => typeof cwe?.cwe_id === 'string' && /^CWE-\d{1,6}$/u.test(cwe.cwe_id) ? [cwe.cwe_id] : []))],
|
|
96
|
+
...dependency.scope === 'development' ? { dev: true as const } : {},
|
|
97
|
+
...state === 'open' ? {} : { dismissed: dismissalOf(state, raw as Record<string, any>) },
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The next page's cursor, as the Link header's `next` names it: its `after`,
|
|
102
|
+
// or its `page`, all that is taken of it, the rest of its URL left as asked.
|
|
103
|
+
function nextPage(link: string | null): [string, string] | null {
|
|
104
|
+
const next = link?.split(',').map(part => /^\s*<([^>]*)>\s*;\s*rel="next"\s*$/u.exec(part)?.[1]).find(Boolean)
|
|
105
|
+
if (!next) return null
|
|
106
|
+
let url
|
|
107
|
+
try { url = new URL(next) } catch { return null }
|
|
108
|
+
if (url.origin !== API) return null
|
|
109
|
+
const after = url.searchParams.get('after'), page = url.searchParams.get('page')
|
|
110
|
+
return after ? ['after', after] : page && /^\d{1,4}$/u.test(page) ? ['page', page] : null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// A page asked of GitHub: its alerts and the headers they came with. A 403
|
|
114
|
+
// that isn't a rate limit is told as the alerts being off
|
|
115
|
+
// (`dependabot-disabled`) or not to be read by the App's installation
|
|
116
|
+
// (`dependabot-app-permission`), as a 404 is, the repository found just
|
|
117
|
+
// before; the rest as githubJson tells them.
|
|
118
|
+
async function askPage(url: string, token: string, fetchImpl: typeof fetch): Promise<{ alerts: unknown[]; link: string | null }> {
|
|
119
|
+
const started = Date.now()
|
|
120
|
+
let res: Response
|
|
121
|
+
try {
|
|
122
|
+
res = await fetchImpl(url, {
|
|
123
|
+
signal: AbortSignal.timeout(PAGE_TIMEOUT_MS), redirect: 'error',
|
|
124
|
+
headers: { authorization: `Bearer ${token}`, accept: 'application/vnd.github+json', 'user-agent': USER_AGENT, 'x-github-api-version': API_VERSION },
|
|
125
|
+
})
|
|
126
|
+
} catch (error) { throw githubUnreachable(error, started) }
|
|
127
|
+
if (res.status === 403) {
|
|
128
|
+
const message = (await res.clone().json().catch(() => null) as { message?: unknown } | null)?.message
|
|
129
|
+
const limited = res.headers.get('x-ratelimit-remaining') === '0' || res.headers.has('retry-after')
|
|
130
|
+
|| (typeof message === 'string' && /rate limit|abuse detection/iu.test(message))
|
|
131
|
+
if (!limited) throw new GithubApiError(403, typeof message === 'string' && /disabled/iu.test(message) ? 'dependabot-disabled' : 'dependabot-app-permission')
|
|
132
|
+
}
|
|
133
|
+
if (res.status === 404) throw new GithubApiError(403, 'dependabot-app-permission')
|
|
134
|
+
if (!res.ok) await throwGithubResponseError(res)
|
|
135
|
+
let body
|
|
136
|
+
try { body = await res.json() } catch (error) {
|
|
137
|
+
throw error instanceof SyntaxError ? new GithubApiError(502, 'github-malformed') : githubUnreachable(error, started)
|
|
138
|
+
}
|
|
139
|
+
if (!Array.isArray(body)) throw new GithubApiError(502, 'github-malformed')
|
|
140
|
+
return { alerts: body, link: res.headers.get('link') }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// `fullName`'s alerts in `states`, MAX_PAGES at most, in GitHub's order (the
|
|
144
|
+
// newest first), and whether that is all of them.
|
|
145
|
+
async function listAlerts(fullName: string, states: readonly string[], token: string, fetchImpl: typeof fetch) {
|
|
146
|
+
const base = `${API}/repos/${fullName.split('/').map(encodeURIComponent).join('/')}/dependabot/alerts`
|
|
147
|
+
const alerts: DependabotAlert[] = []
|
|
148
|
+
const seen = new Set<number>()
|
|
149
|
+
let cursor: [string, string] | null = null
|
|
150
|
+
for (let pages = 0; pages < MAX_PAGES; pages++) {
|
|
151
|
+
const query = new URLSearchParams({ state: states.join(','), ecosystem: 'npm', per_page: String(PER_PAGE), ...cursor ? { [cursor[0]]: cursor[1] } : {} })
|
|
152
|
+
const answer = await askPage(`${base}?${query}`, token, fetchImpl)
|
|
153
|
+
for (const raw of answer.alerts) {
|
|
154
|
+
const alert = alertOf(fullName, raw)
|
|
155
|
+
if (alert && !seen.has(alert.number)) { seen.add(alert.number); alerts.push(alert) }
|
|
156
|
+
}
|
|
157
|
+
cursor = answer.alerts.length === 0 ? null : nextPage(answer.link)
|
|
158
|
+
if (!cursor) return { alerts, whole: true }
|
|
159
|
+
}
|
|
160
|
+
return { alerts, whole: false }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// `fullName`'s Dependabot alerts of npm's ecosystem, open or dismissed, as
|
|
164
|
+
// `token`, the App's installation's (dependabot-access.ts), may see them, in
|
|
165
|
+
// GitHub's order. Asked at once, but where they are more than a listing holds,
|
|
166
|
+
// as newer dismissed ones would leave older open ones out, the open ones are
|
|
167
|
+
// listed again on their own, and the dismissed ones had kept after them.
|
|
168
|
+
export async function dependabotAlerts(fullName: string, token: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<DependabotAlert[]> {
|
|
169
|
+
const all = await listAlerts(fullName, STATES, token, fetchImpl)
|
|
170
|
+
if (all.whole) return all.alerts
|
|
171
|
+
const open = await listAlerts(fullName, ['open'], token, fetchImpl)
|
|
172
|
+
const told = new Set(open.alerts.map(alert => alert.number))
|
|
173
|
+
return [...open.alerts, ...all.alerts.filter(alert => alert.dismissed && !told.has(alert.number))]
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The versions of an alert's package that a lockfile it is of locks
|
|
177
|
+
// (`packages`) and its range covers, GitHub's read as npm's semver reads its
|
|
178
|
+
// own (`>= 1.0.0, < 1.2.6` as `>= 1.0.0 < 1.2.6`); none where it is of no
|
|
179
|
+
// lockfile read, or its range is none semver reads.
|
|
180
|
+
export function alertVersions(alert: Pick<DependabotAlert, 'name' | 'range'>, packages: readonly LockedPackage[] | undefined): string[] {
|
|
181
|
+
const range = alert.range === undefined ? null : validRange(alert.range.replaceAll(',', ' '))
|
|
182
|
+
if (!packages || range === null) return []
|
|
183
|
+
return [...new Set(packages.flatMap(({ name, version, source }) => name === alert.name && (source === 'registry' || source === 'mirror')
|
|
184
|
+
&& isExactVersion(version) && satisfies(version, range) ? [version] : []))]
|
|
185
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// The comment a host formats of one's own dependency reviews, for a copy
|
|
2
|
+
// button to give (reviewed-dependencies.ts `formatDependencyReviews`): of each
|
|
3
|
+
// of one's reviews, as their lists and the review page show them
|
|
4
|
+
// (`withFormatted`), and of those of the versions an open pull request adds
|
|
5
|
+
// (`formattedPull`). It is given one's own published reviews alone, never
|
|
6
|
+
// another's, a draft or a revoked one, however it is asked (`formatOwn`, the
|
|
7
|
+
// only caller of the hook); and is told of none where it answers nothing, or
|
|
8
|
+
// fails, which is logged.
|
|
9
|
+
import type { DependencyReview, DependencyReviewStore } from './dependency-reviews.ts'
|
|
10
|
+
import { type AddedPackage, type PullDependencies, eachOf } from './pull-dependencies.ts'
|
|
11
|
+
import type { PublishedReview, RepoReviewState } from './repository-dependency-reviews.ts'
|
|
12
|
+
import type { DependencyReviewEntry, ReviewedDependencies } from './reviewed-dependencies.ts'
|
|
13
|
+
|
|
14
|
+
const FORMAT_AT_ONCE = 8
|
|
15
|
+
// The longest comment told, past which none is.
|
|
16
|
+
const MAX_FORMATTED = 1_000_000
|
|
17
|
+
// The reviews of a pull request's versions read at once, those past them
|
|
18
|
+
// read after, every one formatted.
|
|
19
|
+
const REVIEWS_AT_ONCE = 200
|
|
20
|
+
|
|
21
|
+
// `review` as the hook is given it, the version it is of as `locked`, a
|
|
22
|
+
// pull request's lockfile, locks it where it is one's.
|
|
23
|
+
function entryOf(review: DependencyReview, locked?: AddedPackage | undefined): DependencyReviewEntry {
|
|
24
|
+
return { ecosystem: 'npm', old: review.basePackage === null ? null : { name: review.basePackage, version: review.baseVersion ?? '', integrity: review.baseIntegrity ?? '' },
|
|
25
|
+
new: { name: review.package, version: review.version, integrity: review.integrity, ...locked?.shasum ? { shasum: locked.shasum } : {},
|
|
26
|
+
...locked?.patchHash ? { patchHash: locked.patchHash } : {} },
|
|
27
|
+
status: review.status!, coverage: review.coverage === 'full' ? 'full' : 'partial', comment: review.comment,
|
|
28
|
+
files: review.entries.filter(entry => entry.status !== null || entry.comment.trim() !== '').map(entry => ({ path: entry.file, status: entry.status, comment: entry.comment })),
|
|
29
|
+
reviewer: review.userLogin, publishedAt: review.publishedAt! }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Whether `review` is `user`'s own, published, not revoked, with its verdict.
|
|
33
|
+
const ownPublished = (review: DependencyReview, user: string) => review.user === user && review.publishedAt !== null && review.revokedAt === null && review.status !== null
|
|
34
|
+
|
|
35
|
+
// What the host formats of `reviews`, of them `user`'s own published ones
|
|
36
|
+
// alone, and the pull request they are of the versions of, where it is
|
|
37
|
+
// one's; none where it formats none of them.
|
|
38
|
+
async function formatOwn(reviewed: ReviewedDependencies | null, user: string, reviews: { review: DependencyReview; locked?: AddedPackage | undefined }[],
|
|
39
|
+
pullRequest?: { repository: string; number: number }): Promise<string | null> {
|
|
40
|
+
const format = reviewed?.formatDependencyReviews
|
|
41
|
+
const own = reviews.filter(({ review }) => ownPublished(review, user))
|
|
42
|
+
if (!format || own.length === 0) return null
|
|
43
|
+
try {
|
|
44
|
+
const text = await format({ reviews: own.map(({ review, locked }) => entryOf(review, locked)), ...pullRequest ? { pullRequest } : {} })
|
|
45
|
+
return typeof text === 'string' && text.trim() !== '' && text.length <= MAX_FORMATTED ? text : null
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.warn('reviewed dependencies: failed to format dependency reviews', error instanceof Error ? error.message : error)
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// `reviews`, each of `user`'s own with the comment the host formats of it
|
|
53
|
+
// (`formatted`), where it formats one; another's never asked of.
|
|
54
|
+
export function withFormatted<T extends DependencyReview>(reviewed: ReviewedDependencies | null, user: string, reviews: T[]): Promise<(T & { formatted?: string })[]> {
|
|
55
|
+
if (!reviewed?.formatDependencyReviews) return Promise.resolve(reviews)
|
|
56
|
+
return eachOf(reviews, FORMAT_AT_ONCE, async review => {
|
|
57
|
+
const formatted = ownPublished(review, user) ? await formatOwn(reviewed, user, [{ review }]) : null
|
|
58
|
+
return formatted ? { ...review, formatted } : review
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Those of `reviews` `user` published of the versions `told` adds, by their
|
|
63
|
+
// ids, with each version as its lockfile locks it: of its version, of an
|
|
64
|
+
// integrity it is locked with and of its patch, whole or compared with a
|
|
65
|
+
// version it was.
|
|
66
|
+
function ownOfPull(told: PullDependencies, reviews: PublishedReview[], user: string): Map<string, AddedPackage> {
|
|
67
|
+
// Theirs by the version they are of, each version probed once.
|
|
68
|
+
const theirs = new Map<string, PublishedReview[]>()
|
|
69
|
+
for (const review of reviews) {
|
|
70
|
+
if (review.id === undefined || review.reviewer !== user) continue
|
|
71
|
+
const key = `${review.package}@${review.version}`
|
|
72
|
+
theirs.set(key, [...theirs.get(key) ?? [], review])
|
|
73
|
+
}
|
|
74
|
+
const mine = new Map<string, AddedPackage>()
|
|
75
|
+
if (theirs.size === 0) return mine
|
|
76
|
+
for (const pkg of told.lockfiles.flatMap(dir => 'added' in dir ? dir.added : [])) {
|
|
77
|
+
const hashes = new Set((pkg.integrity ?? '').split(/\s+/u).filter(Boolean))
|
|
78
|
+
for (const review of theirs.get(`${pkg.name}@${pkg.version}`) ?? []) {
|
|
79
|
+
if (!hashes.has(review.integrity) || (review.patchHash ?? '') !== (pkg.patchHash ?? '')) continue
|
|
80
|
+
if (review.basePackage === null || (review.basePackage === pkg.name && (pkg.was ?? []).includes(review.baseVersion ?? ''))) mine.set(review.id!, pkg)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return mine
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The comment the host formats of `user`'s reviews of the versions pull
|
|
87
|
+
// request `number` of `repository` adds (`told`), those its review's state
|
|
88
|
+
// tells of, published to a team counting for it; none where it has none.
|
|
89
|
+
export async function formattedPull(reviewed: ReviewedDependencies | null, db: Pick<DependencyReviewStore, 'listOwnDependencyReviewsById'>, user: string,
|
|
90
|
+
told: PullDependencies, state: RepoReviewState | null, pullRequest: { repository: string; number: number }): Promise<string | null> {
|
|
91
|
+
if (!reviewed?.formatDependencyReviews || !state) return null
|
|
92
|
+
const mine = ownOfPull(told, state.reviews, user)
|
|
93
|
+
if (mine.size === 0) return null
|
|
94
|
+
const ids = [...mine.keys()], reviews: DependencyReview[] = []
|
|
95
|
+
for (let at = 0; at < ids.length; at += REVIEWS_AT_ONCE) reviews.push(...await db.listOwnDependencyReviewsById(user, ids.slice(at, at + REVIEWS_AT_ONCE)))
|
|
96
|
+
return formatOwn(reviewed, user, reviews.map(review => ({ review, locked: mine.get(review.id) })), pullRequest)
|
|
97
|
+
}
|
|
@@ -25,6 +25,14 @@ export interface DependencyReviewStore {
|
|
|
25
25
|
listDependencyReviews(context: ReviewContext, user: string, allBases?: boolean): Promise<DependencyReview[]>
|
|
26
26
|
getLatestOwnDependencyReview(user: string, target: Pick<ReviewContext, 'ecosystem' | 'package' | 'version' | 'integrity'>): Promise<DependencyReview | null>
|
|
27
27
|
listOwnDependencyReviews(user: string, before: { time: number; id: string } | null): Promise<{ reviews: DependencyReview[]; next: { time: number; id: string } | null }>
|
|
28
|
+
// `user`'s own reviews of `ids`, none another's.
|
|
29
|
+
listOwnDependencyReviewsById(user: string, ids: string[]): Promise<DependencyReview[]>
|
|
30
|
+
// The reviews published to team `teamId`, revoked ones too, the latest
|
|
31
|
+
// published first, a page on from `before` (when it was published, and
|
|
32
|
+
// its id), for one who reads them there (reviewsIn); null for another.
|
|
33
|
+
listTeamDependencyReviews(teamId: string, user: string, before: { time: number; id: string } | null): Promise<{ reviews: DependencyReview[]; next: { time: number; id: string } | null } | null>
|
|
34
|
+
// Whether `user` reads the reviews published to team `teamId` (reviewsIn).
|
|
35
|
+
readsTeamDependencyReviews(teamId: string, user: string): Promise<boolean>
|
|
28
36
|
listDependencyReviewTeams(user: string): Promise<ReviewTeam[]>
|
|
29
37
|
listDependencyReviewEnabledTeams(): Promise<string[]>
|
|
30
38
|
setTeamDependencyReview(sessionId: string, teamId: string, enabled: boolean): Promise<boolean>
|
|
@@ -62,6 +70,7 @@ CREATE TABLE IF NOT EXISTS managed_dependency_review_team (
|
|
|
62
70
|
team_id TEXT NOT NULL REFERENCES managed_team(id) ON DELETE CASCADE,
|
|
63
71
|
PRIMARY KEY (review_id, team_id)
|
|
64
72
|
) STRICT;
|
|
73
|
+
CREATE INDEX IF NOT EXISTS managed_dependency_review_team_idx ON managed_dependency_review_team(team_id, review_id);
|
|
65
74
|
`
|
|
66
75
|
const values = (c: ReviewContext) => [c.ecosystem, c.package, c.version, c.integrity, c.basePackage ?? '', c.baseVersion ?? '', c.baseIntegrity ?? '']
|
|
67
76
|
const targetWhere = 'ecosystem = ? AND package = ? AND version = ? AND integrity = ?'
|
|
@@ -71,6 +80,16 @@ const columns = `id, user_id AS user, user_login AS userLogin, ecosystem, packag
|
|
|
71
80
|
time, revision, published_at AS publishedAt, revoked_at AS revokedAt, files, status, comment`
|
|
72
81
|
const conflict = () => { throw new ManagedMutationError(409, 'review-conflict') }
|
|
73
82
|
|
|
83
|
+
// Who reviews dependencies in team `t`, user `u` as the query joins them: an
|
|
84
|
+
// admin, in any team; a member who manages, in theirs; a member who triages,
|
|
85
|
+
// in theirs whose dependencies they may see (`view_dependencies`). Publishing
|
|
86
|
+
// (`publishing`) needs the team to review dependencies; reading what was
|
|
87
|
+
// published there, not, so what it published stays read once it stops. A
|
|
88
|
+
// hidden team is no one's.
|
|
89
|
+
export const reviewsIn = (publishing: boolean) => `t.hidden = 0${publishing ? ' AND t.dependency_review = 1' : ''} AND (u.role = 'admin' OR EXISTS (
|
|
90
|
+
SELECT 1 FROM managed_team_user m WHERE m.team_id = t.id AND m.user_id = u.id
|
|
91
|
+
AND (u.role = 'manage' OR (u.role = 'triage' AND m.view_dependencies = 1))))`
|
|
92
|
+
|
|
74
93
|
// Fold legacy multi-note files into one text field without changing a frozen
|
|
75
94
|
// snapshot's stored rows. Continuing or saving uses one row per file.
|
|
76
95
|
function fileEntries(entries: ReviewEntry[]): ReviewEntry[] {
|
|
@@ -93,11 +112,18 @@ async function actor(db: ManagedSql, sessionId: string, admin = false) {
|
|
|
93
112
|
if (!(admin ? ['admin'] : ['triage', 'manage', 'admin']).includes(user.role)) throw new ManagedMutationError(403, 'forbidden')
|
|
94
113
|
return user
|
|
95
114
|
}
|
|
115
|
+
// The teams `user` publishes reviews to (reviewsIn).
|
|
96
116
|
async function teams(db: ManagedSql, user: string): Promise<ReviewTeam[]> {
|
|
97
|
-
return await db.prepare(`SELECT t.id, t.name FROM managed_team t
|
|
98
|
-
|
|
99
|
-
|
|
117
|
+
return await db.prepare(`SELECT t.id, t.name FROM managed_team t JOIN managed_user u ON u.id = ?
|
|
118
|
+
WHERE ${reviewsIn(true)} ORDER BY t.name, t.id`).all(user) as ReviewTeam[]
|
|
119
|
+
}
|
|
120
|
+
// A draft is begun or edited by one who publishes to some team; their own
|
|
121
|
+
// published review revoked, or published to more teams, as `publish` checks
|
|
122
|
+
// each, whoever they are now.
|
|
123
|
+
async function requireReviewer(db: ManagedSql, user: string) {
|
|
124
|
+
if ((await teams(db, user)).length === 0) throw new ManagedMutationError(403, 'review-access-required')
|
|
100
125
|
}
|
|
126
|
+
|
|
101
127
|
async function expandReviews(db: ManagedSql, rows: unknown[], user: string): Promise<DependencyReview[]> {
|
|
102
128
|
const reviews = rows as (Omit<DependencyReview, 'files'> & { files: string | null })[]
|
|
103
129
|
if (reviews.length === 0) return []
|
|
@@ -105,10 +131,9 @@ async function expandReviews(db: ManagedSql, rows: unknown[], user: string): Pro
|
|
|
105
131
|
const entries = await db.prepare(`SELECT review_id AS reviewId, id, file, comment, status, time
|
|
106
132
|
FROM managed_dependency_file_review WHERE review_id IN (${placeholders}) ORDER BY time, id`).all(...ids) as (ReviewEntry & { reviewId: string })[]
|
|
107
133
|
const linked = await db.prepare(`SELECT rt.review_id AS reviewId, t.id, t.name FROM managed_dependency_review_team rt
|
|
108
|
-
JOIN managed_dependency_review r ON r.id = rt.review_id JOIN managed_team t ON t.id = rt.team_id
|
|
109
|
-
WHERE rt.review_id IN (${placeholders}) AND (r.user_id =
|
|
110
|
-
|
|
111
|
-
ORDER BY t.name, t.id`).all(...ids, user, user) as (ReviewTeam & { reviewId: string })[]
|
|
134
|
+
JOIN managed_dependency_review r ON r.id = rt.review_id JOIN managed_team t ON t.id = rt.team_id JOIN managed_user u ON u.id = ?
|
|
135
|
+
WHERE rt.review_id IN (${placeholders}) AND (r.user_id = u.id OR (${reviewsIn(false)}))
|
|
136
|
+
ORDER BY t.name, t.id`).all(user, ...ids) as (ReviewTeam & { reviewId: string })[]
|
|
112
137
|
return reviews.map(review => {
|
|
113
138
|
const snapshot = review.files === null ? null : JSON.parse(review.files) as string[] | { paths: string[]; nonTextFiles: string[] }
|
|
114
139
|
const files = Array.isArray(snapshot) ? snapshot : snapshot?.paths ?? null
|
|
@@ -147,6 +172,30 @@ async function listOwn(db: ManagedSql, user: string, before: { time: number; id:
|
|
|
147
172
|
return { reviews: await expandReviews(db, page, user), next: rows.length > 50 && last ? { time: last.time, id: last.id } : null }
|
|
148
173
|
}
|
|
149
174
|
|
|
175
|
+
// A version's reviews, of its base (`context`), or of any (`allBases`), that
|
|
176
|
+
// `user` reads: their own, and those published to a team they read in.
|
|
177
|
+
async function listContext(db: ManagedSql, context: ReviewContext, user: string, allBases: boolean) {
|
|
178
|
+
const rows = await db.prepare(`SELECT ${columns} FROM managed_dependency_review r WHERE ${allBases ? targetWhere : contextWhere}
|
|
179
|
+
AND (user_id = ? OR (published_at IS NOT NULL AND EXISTS (
|
|
180
|
+
SELECT 1 FROM managed_dependency_review_team rt JOIN managed_team t ON t.id = rt.team_id JOIN managed_user u ON u.id = ?
|
|
181
|
+
WHERE rt.review_id = r.id AND ${reviewsIn(false)})))
|
|
182
|
+
ORDER BY time, id`).all(...values(context).slice(0, allBases ? 4 : 7), user, user)
|
|
183
|
+
return expandReviews(db, rows, user)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const readsTeam = async (db: ManagedSql, teamId: string, user: string) =>
|
|
187
|
+
Boolean(await db.prepare(`SELECT 1 FROM managed_team t JOIN managed_user u ON u.id = ? WHERE t.id = ? AND ${reviewsIn(false)}`).get(user, teamId))
|
|
188
|
+
|
|
189
|
+
async function listTeam(db: ManagedSql, teamId: string, user: string, before: { time: number; id: string } | null) {
|
|
190
|
+
if (!await readsTeam(db, teamId, user)) return null
|
|
191
|
+
const rows = await db.prepare(`SELECT ${columns} FROM managed_dependency_review r JOIN managed_dependency_review_team rt ON rt.review_id = r.id
|
|
192
|
+
WHERE rt.team_id = ? AND r.published_at IS NOT NULL ${before ? 'AND (r.published_at < ? OR (r.published_at = ? AND r.id < ?))' : ''}
|
|
193
|
+
ORDER BY r.published_at DESC, r.id DESC LIMIT 51`).all(teamId, ...before ? [before.time, before.time, before.id] : []) as DependencyReview[]
|
|
194
|
+
const page = rows.slice(0, 50)
|
|
195
|
+
const last = page.at(-1)
|
|
196
|
+
return { reviews: await expandReviews(db, page, user), next: rows.length > 50 && last ? { time: Number(last.publishedAt), id: last.id } : null }
|
|
197
|
+
}
|
|
198
|
+
|
|
150
199
|
async function saveFile(db: ManagedSql, reviewId: string, entry: ReviewInput, requestedId: string | null, now: number) {
|
|
151
200
|
const existing = await db.prepare(`SELECT id FROM managed_dependency_file_review
|
|
152
201
|
WHERE review_id = ? AND file = ? ORDER BY time, id`).all(reviewId, entry.file) as { id: string }[]
|
|
@@ -171,6 +220,13 @@ export function dependencyReviewMethods(db: ManagedSql): DependencyReviewStore {
|
|
|
171
220
|
const read = (id: string) => db.prepare(`SELECT ${columns} FROM managed_dependency_review WHERE id = ?`).get(id)
|
|
172
221
|
return {
|
|
173
222
|
listOwnDependencyReviews: (user, before) => listOwn(db, user, before),
|
|
223
|
+
async listOwnDependencyReviewsById(user, ids) {
|
|
224
|
+
if (ids.length === 0) return []
|
|
225
|
+
return expandReviews(db, await db.prepare(`SELECT ${columns} FROM managed_dependency_review WHERE user_id = ? AND id IN (${ids.map(() => '?').join(',')})
|
|
226
|
+
ORDER BY time, id`).all(user, ...ids), user)
|
|
227
|
+
},
|
|
228
|
+
listTeamDependencyReviews: (teamId, user, before) => listTeam(db, teamId, user, before),
|
|
229
|
+
readsTeamDependencyReviews: (teamId, user) => readsTeam(db, teamId, user),
|
|
174
230
|
async getLatestOwnDependencyReview(user, target) {
|
|
175
231
|
const row = await db.prepare(`SELECT ${columns} FROM managed_dependency_review
|
|
176
232
|
WHERE user_id = ? AND ecosystem = ? AND package = ? AND version = ? AND integrity = ? ORDER BY time DESC, id DESC LIMIT 1`)
|
|
@@ -191,16 +247,10 @@ export function dependencyReviewMethods(db: ManagedSql): DependencyReviewStore {
|
|
|
191
247
|
return true
|
|
192
248
|
},
|
|
193
249
|
listDependencyReviewTeams: user => teams(db, user),
|
|
194
|
-
|
|
195
|
-
const rows = await db.prepare(`SELECT ${columns} FROM managed_dependency_review r WHERE ${allBases ? targetWhere : contextWhere}
|
|
196
|
-
AND (user_id = ? OR (published_at IS NOT NULL AND EXISTS (
|
|
197
|
-
SELECT 1 FROM managed_dependency_review_team rt JOIN managed_team t ON t.id = rt.team_id AND t.hidden = 0
|
|
198
|
-
JOIN managed_team_user m ON m.team_id = rt.team_id WHERE rt.review_id = r.id AND m.user_id = ?)))
|
|
199
|
-
ORDER BY time, id`).all(...values(context).slice(0, allBases ? 4 : 7), user, user)
|
|
200
|
-
return expandReviews(db, rows, user)
|
|
201
|
-
},
|
|
250
|
+
listDependencyReviews: (context, user, allBases = false) => listContext(db, context, user, allBases),
|
|
202
251
|
async changeDependencyReview(context, sessionId, change, now) {
|
|
203
252
|
const user = await actor(db, sessionId)
|
|
253
|
+
if (change.action !== 'revoke' && change.action !== 'publish') await requireReviewer(db, user.id)
|
|
204
254
|
let id = change.reviewId
|
|
205
255
|
let current: DependencyReview | undefined
|
|
206
256
|
if (id) {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// GitHub accounts as GitHub's REST and GraphQL APIs name them, where a
|
|
2
|
+
// repository's activity names its authors (repository-history.ts and the
|
|
3
|
+
// Commits, Issues, Agents and Pulse tabs' readers).
|
|
4
|
+
const LOGIN = /^[A-Za-z\d][A-Za-z\d-]{0,38}(?:\[bot\])?$/u
|
|
5
|
+
|
|
6
|
+
// An account as GitHub names it: its login, else null, and its numeric id
|
|
7
|
+
// where GitHub names one with it.
|
|
8
|
+
export interface NamedAccount { login: string | null; id: number | null }
|
|
9
|
+
export function accountOf(account: { login?: unknown; id?: unknown } | null | undefined): NamedAccount {
|
|
10
|
+
const login = typeof account?.login === 'string' && LOGIN.test(account.login) ? account.login : null
|
|
11
|
+
const id = account?.id
|
|
12
|
+
return { login, id: login && Number.isSafeInteger(id) && (id as number) > 0 ? id as number : null }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// An actor (the author of an issue or a pull request, one who merged one) as
|
|
16
|
+
// GitHub's GraphQL API names it: its login, and its account's numeric id, a
|
|
17
|
+
// user's or a GitHub App's bot's.
|
|
18
|
+
export const ACTOR = 'login ... on User { databaseId } ... on Bot { databaseId }'
|
|
19
|
+
export type GraphAccount = { login?: unknown; databaseId?: unknown } | null | undefined
|
|
20
|
+
export const actorOf = (actor: GraphAccount) => accountOf({ login: actor?.login, id: actor?.databaseId })
|
|
21
|
+
|
|
22
|
+
// A commit's author's account, as GitHub's GraphQL API names it: the user
|
|
23
|
+
// GitHub knows its address for; else, as GitHub's REST API does, the account
|
|
24
|
+
// whose noreply address its address is, `<id>+<login>@users.noreply.github.com`:
|
|
25
|
+
// a GitHub App's bot's, which is no user (GraphQL's `user` is a User's, never
|
|
26
|
+
// a bot's).
|
|
27
|
+
const NOREPLY = /^([1-9]\d{0,11})\+([^@]+)@users\.noreply\.github\.com$/iu
|
|
28
|
+
export type GraphActor = { email?: unknown; user?: GraphAccount | null }
|
|
29
|
+
export function commitAccount(author: GraphActor | null | undefined): NamedAccount {
|
|
30
|
+
const user = actorOf(author?.user)
|
|
31
|
+
const noreply = user.id === null && typeof author?.email === 'string' ? NOREPLY.exec(author.email) : null
|
|
32
|
+
return noreply ? accountOf({ login: noreply[2], id: Number(noreply[1]) }) : user
|
|
33
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// GitHub as a risk scan resolves actions with (risk ActionsSource), read with
|
|
2
|
+
// an admin's own token, or none for public repositories alone: what GitHub
|
|
3
|
+
// has none of reads as none, anything else is thrown as a GithubApiError.
|
|
4
|
+
import { createClient } from '@preventive/upstream/github.js'
|
|
5
|
+
import type { ActionsSource } from '../risk/index.js'
|
|
6
|
+
import { GithubApiError, upstreamGithub } from './github-errors.ts'
|
|
7
|
+
import { githubJson } from './github-app.ts'
|
|
8
|
+
|
|
9
|
+
// How many pages of 100 tags are listed; a repository with more is told by
|
|
10
|
+
// its tags one at a time.
|
|
11
|
+
const MAX_TAG_PAGES = 10
|
|
12
|
+
const BRANCHES_PAGE = 100
|
|
13
|
+
|
|
14
|
+
const api = (name: string, suffix: string) => `https://api.github.com/repos/${name.split('/').map(encodeURIComponent).join('/')}${suffix}`
|
|
15
|
+
|
|
16
|
+
// Whether upstream refused what it was asked before asking GitHub, as a ref
|
|
17
|
+
// git wouldn't take: its arguments' checks say what each must be.
|
|
18
|
+
const isRefused = (error: unknown) => error instanceof Error && error.name === 'AssertionError' && / must be /u.test(error.message)
|
|
19
|
+
|
|
20
|
+
// What `read` answers, null where GitHub has none (a 404), or upstream
|
|
21
|
+
// refused to ask.
|
|
22
|
+
async function orNone<T>(read: () => Promise<T>): Promise<T | null> {
|
|
23
|
+
try {
|
|
24
|
+
return await upstreamGithub(() => read().catch((error: unknown) => { throw isRefused(error) ? new GithubApiError(404, 'github-not-found') : error }))
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error instanceof GithubApiError && error.status === 404) return null
|
|
27
|
+
throw error
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface RepoObject { full_name?: unknown; default_branch?: unknown; fork?: unknown; parent?: { full_name?: unknown }; archived?: unknown; private?: unknown }
|
|
32
|
+
|
|
33
|
+
export function githubActionsSource(token: string | null, fetchImpl: typeof fetch = globalThis.fetch): ActionsSource {
|
|
34
|
+
const client = createClient({ token })
|
|
35
|
+
return {
|
|
36
|
+
async repo(name) {
|
|
37
|
+
const info = await orNone(() => client.getRepo({ repo: name, followRedirect: true })) as RepoObject | null
|
|
38
|
+
if (!info) return null
|
|
39
|
+
if (typeof info.full_name !== 'string' || typeof info.default_branch !== 'string') throw new GithubApiError(502, 'github-malformed')
|
|
40
|
+
return { fullName: info.full_name, defaultBranch: info.default_branch, archived: info.archived === true, private: info.private !== false,
|
|
41
|
+
fork: info.fork === true ? (typeof info.parent?.full_name === 'string' ? info.parent.full_name : '') : null }
|
|
42
|
+
},
|
|
43
|
+
tags(name) {
|
|
44
|
+
// More than MAX_TAG_PAGES pages, which upstream refuses to list whole.
|
|
45
|
+
return upstreamGithub(() => client.listRepoTags({ repo: name, maxPages: MAX_TAG_PAGES })
|
|
46
|
+
.catch((error: unknown) => { if (error instanceof Error && /more than \d+ pages/u.test(error.message)) return null; throw error }))
|
|
47
|
+
},
|
|
48
|
+
tag: async (name, tag) => (await orNone(() => client.getRepoTag({ repo: name, tag })))?.oid ?? null,
|
|
49
|
+
branch: async (name, branch) => (await orNone(() => client.getRepoHead({ repo: name, branch })))?.oid ?? null,
|
|
50
|
+
async branches(name) {
|
|
51
|
+
const listed = await githubJson(api(name, `/branches?per_page=${BRANCHES_PAGE}`), token, fetchImpl)
|
|
52
|
+
if (!Array.isArray(listed)) throw new GithubApiError(502, 'github-malformed')
|
|
53
|
+
const branches = (listed as { name?: unknown; commit?: { sha?: unknown } }[]).flatMap(item => typeof item?.name === 'string'
|
|
54
|
+
&& typeof item.commit?.sha === 'string' && /^[\da-f]{40}$/u.test(item.commit.sha) ? [{ branch: item.name, oid: item.commit.sha }] : [])
|
|
55
|
+
return { branches, complete: listed.length < BRANCHES_PAGE }
|
|
56
|
+
},
|
|
57
|
+
// `base...sha`: `sha` is `base` or in its history where GitHub tells it
|
|
58
|
+
// behind or identical, a commit of a fork in the same network too.
|
|
59
|
+
async contains(name, base, sha) {
|
|
60
|
+
if (!/^[\da-f]{40}$/u.test(sha)) return null
|
|
61
|
+
let compared
|
|
62
|
+
try { compared = await githubJson(api(name, `/compare/${base.split('/').map(encodeURIComponent).join('/')}...${sha}?per_page=1`), token, fetchImpl) as { status?: unknown } } catch (error) {
|
|
63
|
+
if (error instanceof GithubApiError && (error.status === 404 || error.message === 'github-status-422')) return null
|
|
64
|
+
throw error
|
|
65
|
+
}
|
|
66
|
+
if (typeof compared?.status !== 'string') throw new GithubApiError(502, 'github-malformed')
|
|
67
|
+
return compared.status === 'behind' || compared.status === 'identical'
|
|
68
|
+
},
|
|
69
|
+
file: (name, path, commit) => orNone(() => client.getRepoFile({ repo: name, path, ref: commit })),
|
|
70
|
+
}
|
|
71
|
+
}
|