@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
|
@@ -12,58 +12,47 @@
|
|
|
12
12
|
// host with an integrity, such as a company's mirror of npm's, as npm's
|
|
13
13
|
// package of its name and version where npm's integrity is among it; one
|
|
14
14
|
// from elsewhere (git, a tarball with no integrity, a file) is listed, not
|
|
15
|
-
// assessed. Repositories are read
|
|
16
|
-
// caller's; packages are assessed meanwhile, as many at a time as npm-risk.ts
|
|
15
|
+
// assessed. Repositories are read a few at a time (READ_AHEAD), each told in
|
|
16
|
+
// turn, as GitHub's limits are the caller's; packages are assessed meanwhile, as many at a time as npm-risk.ts
|
|
17
17
|
// makes spare assessments, those only devDependencies need in every lockfile
|
|
18
18
|
// that locks them (LockedPackage `dev`) once all the rest are, as an install
|
|
19
19
|
// of no devDependencies leaves them out. A lockfile's packages of npm's
|
|
20
20
|
// registry or a mirror are asked of npm's advisories all at once, as `npm
|
|
21
21
|
// audit` asks, each advisory told with whether it is of malware (risk's
|
|
22
22
|
// npmMalwareAdvisory), which alone is a supply chain's risk; the rest are
|
|
23
|
-
// vulnerabilities, told beside.
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
// vulnerabilities, told beside. A repository's Dependabot alerts of npm's, open or dismissed,
|
|
24
|
+
// are told once its lockfiles' advisories are, each with the versions its
|
|
25
|
+
// lockfile locks that it covers, for the page to blend them into npm's; one
|
|
26
|
+
// whose alerts couldn't be had tells why, and the scan goes on. Its GitHub
|
|
27
|
+
// Actions are told once its lockfiles are, the references they make
|
|
28
|
+
// resolved as the scan goes on (repository-actions.ts).
|
|
29
|
+
import type { LockfileFormat } from '@preventive/upstream/github.js'
|
|
30
|
+
import { type Severity, npmMalwareAdvisory } from '../risk/index.js'
|
|
26
31
|
import { isNpmPackageName, isNpmPackageSpec } from '../common/managed/npm-packages.js'
|
|
27
32
|
import type { NpmVersionDocument } from './npm-packages.ts'
|
|
28
33
|
import { type Lru, lru } from './lru.ts'
|
|
29
34
|
import type { KeptStore } from './npm-kept.ts'
|
|
30
35
|
import { MAX_SPARE_ASSESSMENTS } from './npm-risk.ts'
|
|
31
|
-
import {
|
|
36
|
+
import type { LockedPackage } from './locked-packages.ts'
|
|
32
37
|
import { type Queue, queue } from './queue.ts'
|
|
33
38
|
import { type DownloadsSource, readyingDownloads } from './scan-downloads.ts'
|
|
34
39
|
import { type AssessedPackage, type Assessment, BUSY_RETRIES, BUSY_WAIT_MS, RISK_KEPT_MS, type RiskFailure, assessPackage, failureOf, isBusy } from './repository-risk-assess.ts'
|
|
35
40
|
import { type AdvisoryRow, type Audit, type AuditSource, auditLockfile } from './repository-risk-audit.ts'
|
|
41
|
+
import { type DependabotRead, type ListedLockfile, type LockfileRead, type OpenedRepository, type RepositoryFailure, type RepositoryRead, SCAN_READ_KEPT_MS,
|
|
42
|
+
readRepository } from './repository-risk-read.ts'
|
|
43
|
+
import { type DependabotAlert, alertVersions } from './dependabot-alerts.ts'
|
|
44
|
+
import type { ActionsSource } from '../risk/index.js'
|
|
45
|
+
import { type ActionAnswers, type ActionLine, type ActionRef, type WorkflowLine, type WorkflowsLine, type WorkflowsRead, actionsLines, answerWeight,
|
|
46
|
+
lockedNames, resolvingActions } from './repository-actions.ts'
|
|
36
47
|
|
|
37
48
|
export { RISK_KEPT_MS, highestSeverity } from './repository-risk-assess.ts'
|
|
38
|
-
|
|
39
|
-
// The formats of npm's ecosystem, as findRepoLockfiles names them, and those
|
|
40
|
-
// of them read here.
|
|
41
|
-
const NPM_LOCKFILE_FORMATS: ReadonlySet<LockfileFormat> = new Set(['npm', 'pnpm', 'yarn', 'bun', 'deno'])
|
|
42
|
-
const READ_FORMATS: ReadonlySet<LockfileFormat> = new Set(['npm', 'pnpm', 'yarn'])
|
|
49
|
+
export { type OpenedRepository, SCAN_READ_KEPT_MS } from './repository-risk-read.ts'
|
|
43
50
|
|
|
44
51
|
// A repository to read: where `paths` are given, a team's directories in it,
|
|
45
52
|
// only the lockfiles in them or above them, as a workspace's root lockfile
|
|
46
53
|
// locks them too.
|
|
47
54
|
export interface RepositoryRiskRepo { repoId: number; fullName: string; paths?: readonly string[] }
|
|
48
55
|
|
|
49
|
-
// Whether a lockfile at `path` is of `paths` (RepositoryRiskRepo).
|
|
50
|
-
function lockfileOf(path: string, paths: readonly string[] | undefined) {
|
|
51
|
-
const dir = path.slice(0, Math.max(path.lastIndexOf('/'), 0))
|
|
52
|
-
return !paths || paths.some(scope => dir === '' || dir === scope || dir.startsWith(`${scope}/`) || scope.startsWith(`${dir}/`))
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
type ListedLockfile = Pick<RepoLockfile, 'path' | 'format' | 'sha'>
|
|
56
|
-
|
|
57
|
-
// A repository opened at its default branch's head (`commit`): its lockfiles,
|
|
58
|
-
// as findRepoLockfiles lists them, each with its blob's id, and a file's
|
|
59
|
-
// text; `recheck` checks access to it again, throwing where it is gone.
|
|
60
|
-
export interface OpenedRepository {
|
|
61
|
-
commit: string
|
|
62
|
-
lockfiles(): Promise<ListedLockfile[]>
|
|
63
|
-
file(path: string): Promise<string>
|
|
64
|
-
recheck(): Promise<void>
|
|
65
|
-
}
|
|
66
|
-
|
|
67
56
|
export interface RepositoryRiskSource<R extends RepositoryRiskRepo> extends AuditSource {
|
|
68
57
|
repos(): Promise<R[]>
|
|
69
58
|
open(repo: R): Promise<OpenedRepository>
|
|
@@ -94,10 +83,21 @@ export interface RepositoryRiskSource<R extends RepositoryRiskRepo> extends Audi
|
|
|
94
83
|
// Readies npm's downloads of packages before they are assessed
|
|
95
84
|
// (scan-downloads.ts); without it, each assessment asks its own.
|
|
96
85
|
downloads?: DownloadsSource
|
|
86
|
+
// GitHub as references are resolved with; none without it.
|
|
87
|
+
actions?: ActionsSource
|
|
88
|
+
// Where Actions' files read and references resolved are kept.
|
|
89
|
+
actionFiles?: KeptStore<WorkflowsRead>
|
|
90
|
+
actionLines?: KeptStore<ActionLine>
|
|
97
91
|
}
|
|
98
92
|
|
|
99
93
|
|
|
100
94
|
|
|
95
|
+
// A Dependabot alert as a scan tells it: with the versions of its package
|
|
96
|
+
// the lockfile it is of locks that it covers, none where it is of none read
|
|
97
|
+
// (a package.json, bun's lockfile), and whether it is of malware, as an
|
|
98
|
+
// advisory's is told.
|
|
99
|
+
export type DependabotAlertRow = DependabotAlert & { versions: string[]; malware: boolean }
|
|
100
|
+
|
|
101
101
|
// A risk line is of npm's package of its name and version, or, with an
|
|
102
102
|
// `integrity`, of a mirror's that its lockfiles hold to it; its `score` risk's
|
|
103
103
|
// of it, out of 100 (assessNpm `score`, risk/npm/score.js).
|
|
@@ -108,6 +108,8 @@ export type RepositoryRiskLine =
|
|
|
108
108
|
| ({ type: 'risk'; name: string; version: string; integrity?: string; stale?: { assessedAt: number } }
|
|
109
109
|
& ({ published: string | null; severity: Severity | null; score: number; findings: { id: string; severity: Severity; takenDown?: true }[] } | RiskFailure))
|
|
110
110
|
| ({ type: 'advisories'; repo: number; path: string } & ({ advisories: AdvisoryRow[] } | RiskFailure))
|
|
111
|
+
| ({ type: 'dependabot'; repo: number } & ({ alerts: DependabotAlertRow[] } | RiskFailure))
|
|
112
|
+
| WorkflowsLine | WorkflowLine | ActionLine
|
|
111
113
|
| { type: 'done' }
|
|
112
114
|
// The scan stopped for its part's time (repositoryRiskLines `partMs`), what
|
|
113
115
|
// it told kept: to be asked again, from where it stopped.
|
|
@@ -115,12 +117,12 @@ export type RepositoryRiskLine =
|
|
|
115
117
|
|
|
116
118
|
// The repositories a scan answered in parts told whole by a part's end: each
|
|
117
119
|
// by its id, its name and a team's directories in it then, and its
|
|
118
|
-
// lockfiles' keys (`${format}\0${sha}`) of those read
|
|
120
|
+
// lockfiles' keys (`${format}\0${sha}`) of those read, and the references
|
|
121
|
+
// its Actions make, to resolve.
|
|
119
122
|
export interface ScanProgress { repos: ToldRepository[] }
|
|
120
|
-
interface ToldRepository { id: number; name: string; paths: readonly string[] | null; lockfiles: string[] }
|
|
123
|
+
interface ToldRepository { id: number; name: string; paths: readonly string[] | null; lockfiles: string[]; actions?: ActionRef[] }
|
|
121
124
|
|
|
122
125
|
type RiskLine = Extract<RepositoryRiskLine, { type: 'risk' }>
|
|
123
|
-
type LockfileRead = { packages: LockedPackage[] } | RiskFailure
|
|
124
126
|
|
|
125
127
|
// What scans keep between them, in this instance's memory: a repository's
|
|
126
128
|
// lockfiles as listed at a commit, and a lockfile's packages by its blob,
|
|
@@ -135,6 +137,9 @@ export interface RiskCaches {
|
|
|
135
137
|
lockfiles: Lru<LockfileRead>
|
|
136
138
|
risks: Lru<{ at: number; line: RiskLine }>
|
|
137
139
|
advisories: Lru<{ at: number; audit: Audit }>
|
|
140
|
+
actionFiles: Lru<WorkflowsRead>
|
|
141
|
+
actions: Lru<{ at: number; line: ActionLine }>
|
|
142
|
+
actionAnswers: ActionAnswers
|
|
138
143
|
}
|
|
139
144
|
export const riskCaches = (): RiskCaches => ({
|
|
140
145
|
listings: lru(1000),
|
|
@@ -142,10 +147,16 @@ export const riskCaches = (): RiskCaches => ({
|
|
|
142
147
|
lockfiles: lru(200_000, read => 'packages' in read ? read.packages.length + 1 : 1),
|
|
143
148
|
risks: lru(50_000),
|
|
144
149
|
advisories: lru(50_000, kept => Array.isArray(kept.audit) ? kept.audit.length + 1 : 1),
|
|
150
|
+
// By their text, a kilobyte at a time.
|
|
151
|
+
actionFiles: lru(64 * 1024, read => 'files' in read ? read.files.reduce((sum, file) => sum + ('text' in file ? Math.ceil(file.text.length / 1024) : 1), 1) : 1),
|
|
152
|
+
actions: lru(10_000),
|
|
153
|
+
// What GitHub answered of public repositories' actions, by answerWeight.
|
|
154
|
+
actionAnswers: lru(200_000, answerWeight),
|
|
145
155
|
})
|
|
146
|
-
// How
|
|
147
|
-
//
|
|
148
|
-
|
|
156
|
+
// How many repositories are read at once (repository-risk-read.ts), ahead of
|
|
157
|
+
// the one told: each asks GitHub one thing after another (opening it, and
|
|
158
|
+
// its head) even where all else is kept.
|
|
159
|
+
const READ_AHEAD = 6
|
|
149
160
|
// How long a part's progress, written as it ends (`progress`), is waited on.
|
|
150
161
|
const PROGRESS_WRITE_MS = 5000
|
|
151
162
|
// How many kept risk lines are read of the source's `risks` at once.
|
|
@@ -168,6 +179,7 @@ interface Scan {
|
|
|
168
179
|
stale: AssessedPackage[]
|
|
169
180
|
// The repositories told whole, this part or one before (ScanProgress).
|
|
170
181
|
told: Map<number, ToldRepository>
|
|
182
|
+
actions: ReturnType<typeof resolvingActions> | null
|
|
171
183
|
}
|
|
172
184
|
|
|
173
185
|
// A package as a scan meets it: by its name, version and a mirror's integrity.
|
|
@@ -255,36 +267,6 @@ async function assessing(scan: Scan, feed: (assess: (packages: AssessedPackage[]
|
|
|
255
267
|
await Promise.all(assessors)
|
|
256
268
|
}
|
|
257
269
|
|
|
258
|
-
type LockfileLine = Extract<RepositoryRiskLine, { type: 'lockfile' }>
|
|
259
|
-
|
|
260
|
-
// What is kept by `key`: in this instance's memory, else in `store` within
|
|
261
|
-
// SCAN_READ_KEPT_MS, else `read` and kept in both.
|
|
262
|
-
async function keptRead<T>(memory: Lru<T>, store: KeptStore<T> | undefined, key: string, read: () => Promise<T>) {
|
|
263
|
-
const known = memory.get(key)
|
|
264
|
-
if (known !== undefined) return known
|
|
265
|
-
const kept = await store?.read(key)
|
|
266
|
-
if (kept && Date.now() - kept.at < SCAN_READ_KEPT_MS) { memory.set(key, kept.value); return kept.value }
|
|
267
|
-
const value = await read()
|
|
268
|
-
memory.set(key, value)
|
|
269
|
-
await store?.write(key, value, Date.now())
|
|
270
|
-
return value
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// A lockfile's line, its packages or why they couldn't be had: as kept by its
|
|
274
|
-
// blob, else read from GitHub and kept.
|
|
275
|
-
async function readLockfile({ source, caches }: Scan, repo: number, opened: OpenedRepository, { path, format, sha }: ListedLockfile): Promise<LockfileLine> {
|
|
276
|
-
const at = { type: 'lockfile', repo, path, format } as const
|
|
277
|
-
if (!READ_FORMATS.has(format)) return { ...at, error: 'unsupported-format' }
|
|
278
|
-
try {
|
|
279
|
-
return { ...at, ...await keptRead(caches.lockfiles, source.lockfiles, `${format}\0${sha}`, async (): Promise<LockfileRead> => {
|
|
280
|
-
const text = await opened.file(path)
|
|
281
|
-
try { return { packages: lockedPackages(format, text) } } catch (error) {
|
|
282
|
-
return { error: 'lockfile-unreadable', ...error instanceof Error ? { detail: error.message.slice(0, 500) } : {} }
|
|
283
|
-
}
|
|
284
|
-
}) }
|
|
285
|
-
} catch (error) { return { ...at, ...failureOf(error) } }
|
|
286
|
-
}
|
|
287
|
-
|
|
288
270
|
// A lockfile's packages of npm's registry or a mirror not yet seen this scan,
|
|
289
271
|
// to look up as kept (lookUp), and assess where none is; and one seen as only
|
|
290
272
|
// devDependencies need it, and put off, that this lockfile needs otherwise,
|
|
@@ -303,25 +285,8 @@ function assessLater(scan: Scan, packages: LockedPackage[]) {
|
|
|
303
285
|
if (fresh.length > 0) scan.lookups.add(fresh)
|
|
304
286
|
}
|
|
305
287
|
|
|
306
|
-
// A repository opened and its lockfiles of npm's ecosystem listed, as kept
|
|
307
|
-
// for its commit or else from GitHub, those of its `paths` alone; null, with
|
|
308
|
-
// its line telling why, where either failed, or where the scan stopped.
|
|
309
|
-
async function listLockfiles(scan: Scan, repo: RepositoryRiskRepo) {
|
|
310
|
-
let doing = 'opening it'
|
|
311
|
-
try {
|
|
312
|
-
const opened = await scan.source.open(repo)
|
|
313
|
-
if (scan.signal.aborted) return null
|
|
314
|
-
doing = 'listing its lockfiles'
|
|
315
|
-
const listed = await keptRead(scan.caches.listings, scan.source.listings, `${repo.repoId}@${opened.commit}`, () => opened.lockfiles())
|
|
316
|
-
const lockfiles = listed.filter(({ path, format }) => NPM_LOCKFILE_FORMATS.has(format) && lockfileOf(path, repo.paths))
|
|
317
|
-
return { opened, lockfiles }
|
|
318
|
-
} catch (error) {
|
|
319
|
-
if (!scan.signal.aborted) scan.lines.add({ type: 'repo', id: repo.repoId, ...failureOf(error, doing) })
|
|
320
|
-
return null
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
288
|
type AdvisoriesLine = Extract<RepositoryRiskLine, { type: 'advisories' }>
|
|
289
|
+
type LockfileLine = Extract<RepositoryRiskLine, { type: 'lockfile' }>
|
|
325
290
|
|
|
326
291
|
// A lockfile's advisories line (repository-risk-audit.ts auditLockfile);
|
|
327
292
|
// null where it locks none, or once the scan stops.
|
|
@@ -331,38 +296,67 @@ async function readAdvisories(scan: Scan, { repo, path, format, packages }: Lock
|
|
|
331
296
|
return audit === null ? null : Array.isArray(audit) ? { ...at, advisories: audit } : { ...at, ...audit }
|
|
332
297
|
}
|
|
333
298
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
299
|
+
type DependabotLine = Extract<RepositoryRiskLine, { type: 'dependabot' }>
|
|
300
|
+
|
|
301
|
+
// A repository's Dependabot alerts' line, each alert with the versions the
|
|
302
|
+
// lockfile it is of locks that it covers (`lockfiles`, a repository read's),
|
|
303
|
+
// or why they couldn't be had.
|
|
304
|
+
function dependabotLine(repo: number, read: DependabotRead, lockfiles: RepositoryRead['lockfiles']): DependabotLine {
|
|
305
|
+
if ('error' in read) return { type: 'dependabot', repo, ...read }
|
|
306
|
+
const locked = new Map(lockfiles.flatMap(({ line }) => 'packages' in line ? [[line.path, line.packages] as const] : []))
|
|
307
|
+
return { type: 'dependabot', repo, alerts: read.alerts.map(alert => ({ ...alert, versions: alertVersions(alert, locked.get(alert.manifest)),
|
|
308
|
+
malware: npmMalwareAdvisory({ id: alert.ghsa, aliases: alert.aliases, cwe: alert.cwe, title: alert.title }) })) }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// A repository's alerts checked again just before they are told, as access to
|
|
312
|
+
// the repository is (DependabotRead `recheck`): why not, where the user's
|
|
313
|
+
// access to them is withdrawn since they were read.
|
|
314
|
+
async function rechecked(read: DependabotRead): Promise<DependabotRead> {
|
|
315
|
+
if (!('alerts' in read) || !read.recheck) return read
|
|
316
|
+
try { await read.recheck() } catch (error) { return failureOf(error, 'checking access to its Dependabot alerts again') }
|
|
317
|
+
return read
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// A repository read (repository-risk-read.ts readRepository) told: its
|
|
321
|
+
// line, or why it couldn't be read, then its lockfiles', and their
|
|
322
|
+
// advisories, its packages assessed meanwhile, then its Dependabot alerts.
|
|
323
|
+
// Access to it is checked again
|
|
324
|
+
// just before, however long ago it was read, as GitHub may have withdrawn it.
|
|
325
|
+
async function tellRepository(scan: Scan, repo: RepositoryRiskRepo, read: RepositoryRead | RepositoryFailure | null) {
|
|
326
|
+
if (!read || scan.signal.aborted) return
|
|
327
|
+
if ('type' in read) { scan.lines.add(read); return }
|
|
328
|
+
try { await read.recheck() } catch (error) {
|
|
349
329
|
if (!scan.signal.aborted) scan.lines.add({ type: 'repo', id: repo.repoId, ...failureOf(error, 'checking access to it again') })
|
|
350
330
|
return
|
|
351
331
|
}
|
|
352
332
|
if (scan.signal.aborted) return
|
|
353
|
-
|
|
333
|
+
const { commit, lockfiles } = read
|
|
334
|
+
scan.lines.add({ type: 'repo', id: repo.repoId, commit, lockfiles: lockfiles.map(({ line: { path, format } }) => ({ path, format })) })
|
|
354
335
|
for (const { line } of lockfiles) {
|
|
355
336
|
scan.lines.add(line)
|
|
356
337
|
if ('packages' in line) assessLater(scan, line.packages)
|
|
357
338
|
}
|
|
339
|
+
const refs = read.actions ? tellActions(scan, repo, read.actions, lockfiles) : []
|
|
358
340
|
for (const { sha, line } of lockfiles) {
|
|
359
341
|
if (scan.signal.aborted) return
|
|
360
342
|
const advisories = 'packages' in line ? await readAdvisories(scan, line, sha) : null
|
|
361
343
|
if (advisories) scan.lines.add(advisories)
|
|
362
344
|
}
|
|
363
345
|
if (scan.signal.aborted) return
|
|
346
|
+
const dependabot = read.dependabot && await rechecked(read.dependabot)
|
|
347
|
+
if (scan.signal.aborted) return
|
|
348
|
+
if (dependabot) scan.lines.add(dependabotLine(repo.repoId, dependabot, lockfiles))
|
|
364
349
|
scan.told.set(repo.repoId, { id: repo.repoId, name: repo.fullName, paths: repo.paths ?? null,
|
|
365
|
-
lockfiles: lockfiles.flatMap(({ sha, line }) => 'packages' in line ? [`${line.format}\0${sha}`] : []) })
|
|
350
|
+
lockfiles: lockfiles.flatMap(({ sha, line }) => 'packages' in line ? [`${line.format}\0${sha}`] : []), ...refs.length > 0 ? { actions: refs } : {} })
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// A repository's Actions told, and the references they make to resolve.
|
|
354
|
+
function tellActions(scan: Scan, repo: RepositoryRiskRepo, read: WorkflowsRead, lockfiles: RepositoryRead['lockfiles']) {
|
|
355
|
+
const locked = lockfiles.flatMap(({ line }) => 'packages' in line ? [lockedNames(line.path, line.packages)] : [])
|
|
356
|
+
const { lines, refs } = actionsLines(repo.repoId, repo.fullName.split('/')[0]!, read, locked)
|
|
357
|
+
for (const line of lines) scan.lines.add(line)
|
|
358
|
+
scan.actions?.add(refs)
|
|
359
|
+
return refs
|
|
366
360
|
}
|
|
367
361
|
|
|
368
362
|
// Whether an earlier part told `repo` whole, by its name and a team's
|
|
@@ -379,11 +373,12 @@ async function toldBefore(scan: Scan, repo: RepositoryRiskRepo) {
|
|
|
379
373
|
reads.push(read.packages)
|
|
380
374
|
}
|
|
381
375
|
for (const packages of reads) assessLater(scan, packages)
|
|
376
|
+
scan.actions?.add(told.actions ?? [])
|
|
382
377
|
return true
|
|
383
378
|
}
|
|
384
379
|
|
|
385
|
-
// Each repository,
|
|
386
|
-
// whole (ScanProgress).
|
|
380
|
+
// Each repository, READ_AHEAD read at a time, each told in turn, but those an
|
|
381
|
+
// earlier part told whole (ScanProgress), read of none.
|
|
387
382
|
async function readRepositories(scan: Scan) {
|
|
388
383
|
const repos = await scan.source.repos()
|
|
389
384
|
scan.lines.add({ type: 'repos', repos: repos.map(repo => ({ id: repo.repoId, fullName: repo.fullName })) })
|
|
@@ -393,9 +388,19 @@ async function readRepositories(scan: Scan) {
|
|
|
393
388
|
const kept = await scan.source.progress?.read()
|
|
394
389
|
const progress = kept && Date.now() - kept.at < SCAN_READ_KEPT_MS ? kept.value.repos : []
|
|
395
390
|
for (const told of progress) if (listed.has(told.id)) scan.told.set(told.id, told)
|
|
396
|
-
|
|
391
|
+
const reads: Promise<RepositoryRead | RepositoryFailure | null>[] = []
|
|
392
|
+
const read = (at: number) => {
|
|
393
|
+
if (reads[at]) return reads[at]
|
|
394
|
+
const repo = repos[at]!
|
|
395
|
+
const started = reads[at] = toldBefore(scan, repo).then(told => told ? null : readRepository(scan, repo))
|
|
396
|
+
// One read ahead and no longer waited on, as the scan stopped, fails alone.
|
|
397
|
+
started.catch(() => {})
|
|
398
|
+
return started
|
|
399
|
+
}
|
|
400
|
+
for (const [at, repo] of repos.entries()) {
|
|
397
401
|
if (scan.signal.aborted) return
|
|
398
|
-
|
|
402
|
+
for (let ahead = at; ahead < Math.min(at + READ_AHEAD, repos.length); ahead++) void read(ahead)
|
|
403
|
+
await tellRepository(scan, repo, await read(at))
|
|
399
404
|
}
|
|
400
405
|
}
|
|
401
406
|
|
|
@@ -431,7 +436,9 @@ export async function* repositoryRiskLines<R extends RepositoryRiskRepo>(source:
|
|
|
431
436
|
// none waited for: the work under way, such as a request to GitHub, which
|
|
432
437
|
// isn't given up on at once, outlasting it.
|
|
433
438
|
const lines = queue<RepositoryRiskLine>(AbortSignal.any([signal, part.signal]), { drain: true })
|
|
434
|
-
const
|
|
439
|
+
const actions = source.actions ? resolvingActions({ source: source.actions, signal: running, tell: line => { lines.add(line) }, memory: caches.actions,
|
|
440
|
+
answers: caches.actionAnswers, store: source.actionLines }) : null
|
|
441
|
+
const scan: Scan = { source, caches, signal: running, lines, lookups: queue(running), seen: new Map(), dev: new Map(), stale: [], told: new Map(), actions }
|
|
435
442
|
const failed: unknown[] = []
|
|
436
443
|
void (async () => {
|
|
437
444
|
try {
|
|
@@ -446,6 +453,7 @@ export async function* repositoryRiskLines<R extends RepositoryRiskRepo>(source:
|
|
|
446
453
|
for (const left of leftOver(scan)) {
|
|
447
454
|
if (!running.aborted && left.length > 0) await assessing(scan, assess => { assess(left); return Promise.resolve() })
|
|
448
455
|
}
|
|
456
|
+
await actions?.close()
|
|
449
457
|
if (!running.aborted) scan.lines.add({ type: 'done' })
|
|
450
458
|
} catch (error) {
|
|
451
459
|
failed.push(error)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// The reviewed dependencies a host keeps beyond the app, as it registers
|
|
2
|
+
// them before the server is assembled (server.ts registerReviewedDependencies):
|
|
3
|
+
// an async method telling whether a dependency, as a lockfile locks it, is
|
|
4
|
+
// reviewed there (`lookup`), and, optionally, one reading the reviews of
|
|
5
|
+
// dependencies an open pull request's comments make (`parseComments`,
|
|
6
|
+
// pull-comment-reviews.ts), and one formatting one's own dependency reviews
|
|
7
|
+
// as a comment, for a copy button to give (`formatDependencyReviews`,
|
|
8
|
+
// dependency-review-comments.ts). Registered, they replace the commit a repository
|
|
9
|
+
// is reviewed since (pull-dependency-reviews.ts): a version passes by the
|
|
10
|
+
// reviews its teams publish, and those its pull request's comments make, as
|
|
11
|
+
// many as the repository requires, or by `lookup`'s saying so, and is
|
|
12
|
+
// otherwise unreviewed. A patched version (pnpm's patchedDependencies)
|
|
13
|
+
// passes by `lookup`, or reviews its comments make of its patch, alone, the
|
|
14
|
+
// app reviewing no patch yet. Those comments' reviews count for the pull
|
|
15
|
+
// request alone, as it is open: the host's to keep, as it is merged, among
|
|
16
|
+
// those `lookup` tells of.
|
|
17
|
+
//
|
|
18
|
+
// Each version is asked of `lookup` once a request, as many at once as
|
|
19
|
+
// ASK_AT_ONCE, its answer kept in this instance KEPT_MS; one it fails to
|
|
20
|
+
// answer is unreviewed, and counted so (`unasked`).
|
|
21
|
+
import { type Lru, lru } from './lru.ts'
|
|
22
|
+
|
|
23
|
+
export type ReviewedLockfile = 'npm' | 'pnpm' | 'yarn1'
|
|
24
|
+
// A dependency as a lockfile locks it: npm's always with its integrity (one
|
|
25
|
+
// with none is never reviewed, nor asked of), its shasum where the lockfile
|
|
26
|
+
// has one (Yarn 1's, after a tarball's `#`), and the hash of its patch where
|
|
27
|
+
// it is patched (pnpm's).
|
|
28
|
+
export interface ReviewedDependency {
|
|
29
|
+
ecosystem: 'npm'
|
|
30
|
+
lockfile: ReviewedLockfile
|
|
31
|
+
name: string
|
|
32
|
+
version: string
|
|
33
|
+
integrity?: string
|
|
34
|
+
shasum?: string
|
|
35
|
+
patchHash?: string
|
|
36
|
+
}
|
|
37
|
+
export type ReviewVerdict = 'accept' | 'accept-with-nits' | 'request-changes' | 'reject'
|
|
38
|
+
// A comment of an open pull request's, as `parseComments` is given it: one
|
|
39
|
+
// on the pull request (`comment`), a review's own (`review`, whatever its
|
|
40
|
+
// state, a comment's alone too), or one on a line of its diff
|
|
41
|
+
// (`review-comment`); by its author's GitHub login; when it was written, and
|
|
42
|
+
// last edited (`editedAt`) where it was.
|
|
43
|
+
export interface ReviewComment {
|
|
44
|
+
kind: 'comment' | 'review' | 'review-comment'
|
|
45
|
+
id: string
|
|
46
|
+
author: string | null
|
|
47
|
+
body: string
|
|
48
|
+
state?: string
|
|
49
|
+
createdAt: string
|
|
50
|
+
editedAt?: string
|
|
51
|
+
url: string
|
|
52
|
+
}
|
|
53
|
+
// What `parseComments` reads: the pull request, its comments, and the
|
|
54
|
+
// dependencies: every one the lockfiles its review holds to add that can be
|
|
55
|
+
// reviewed and `lookup` doesn't pass, and the others still wanting reviews.
|
|
56
|
+
export interface ParseCommentsInput { repository: string; number: number; comments: ReviewComment[]; dependencies: ReviewedDependency[] }
|
|
57
|
+
// A review its comments make, by `user` (a GitHub login, one of their
|
|
58
|
+
// authors'), of each of `dependencies`, with its verdict, as of `time` (when
|
|
59
|
+
// it was made, an ISO date or milliseconds; that user's latest comment's
|
|
60
|
+
// where none is told).
|
|
61
|
+
export interface CommentReview {
|
|
62
|
+
user: string
|
|
63
|
+
time?: string | number
|
|
64
|
+
dependencies: { ecosystem?: 'npm'; name: string; version: string; integrity: string; patchHash?: string; status: ReviewVerdict }[]
|
|
65
|
+
}
|
|
66
|
+
// A version a dependency review is of, or compared it with: its package's
|
|
67
|
+
// name, its version and integrity, and its tarball's shasum and its patch's
|
|
68
|
+
// hash where they are known (a lockfile's, pnpm's patched dependencies').
|
|
69
|
+
export interface ReviewedVersion { name: string; version: string; integrity: string; shasum?: string; patchHash?: string }
|
|
70
|
+
// One of one's own published dependency reviews, as `formatDependencyReviews`
|
|
71
|
+
// is given it: the version it is of (`new`) and the one it compared it with
|
|
72
|
+
// (`old`, none where it read its package whole); its verdict (`status`);
|
|
73
|
+
// whether it read every file (`coverage`, `full`, else `partial`); its final
|
|
74
|
+
// comment; each file's verdict and comment (`files`, of those with either);
|
|
75
|
+
// by whom (`reviewer`, their GitHub login), and when it was published.
|
|
76
|
+
export interface DependencyReviewEntry {
|
|
77
|
+
ecosystem: 'npm'
|
|
78
|
+
old: ReviewedVersion | null
|
|
79
|
+
new: ReviewedVersion
|
|
80
|
+
status: ReviewVerdict
|
|
81
|
+
coverage: 'full' | 'partial'
|
|
82
|
+
comment: string
|
|
83
|
+
files: { path: string; status: ReviewVerdict | null; comment: string }[]
|
|
84
|
+
reviewer: string
|
|
85
|
+
publishedAt: number
|
|
86
|
+
}
|
|
87
|
+
// What `formatDependencyReviews` formats: one's own reviews, never another's,
|
|
88
|
+
// and the pull request whose versions they are of, where its copy asks.
|
|
89
|
+
export interface FormatDependencyReviewsInput { reviews: DependencyReviewEntry[]; pullRequest?: { repository: string; number: number } }
|
|
90
|
+
export interface ReviewedDependencies {
|
|
91
|
+
lookup(dependency: ReviewedDependency): Promise<boolean>
|
|
92
|
+
parseComments?(input: ParseCommentsInput): Promise<CommentReview[]>
|
|
93
|
+
// The comment to copy, none where it is empty, null or undefined.
|
|
94
|
+
formatDependencyReviews?(input: FormatDependencyReviewsInput): Promise<string | null | undefined>
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const ASK_AT_ONCE = 16
|
|
98
|
+
const KEPT_MS = 60_000
|
|
99
|
+
|
|
100
|
+
let registered: ReviewedDependencies | null = null
|
|
101
|
+
let assembled = false
|
|
102
|
+
// Each lookup's answers, apart.
|
|
103
|
+
const answers = new WeakMap<ReviewedDependencies['lookup'], Lru<{ at: number; reviewed: boolean }>>()
|
|
104
|
+
|
|
105
|
+
// Registers the host's reviewed dependencies, once, before the server is
|
|
106
|
+
// assembled (init()): a server already assembled keeps what it was given.
|
|
107
|
+
export function registerReviewedDependencies(reviewed: ReviewedDependencies): void {
|
|
108
|
+
const optional = (method: unknown) => method === undefined || typeof method === 'function'
|
|
109
|
+
if (typeof reviewed?.lookup !== 'function' || !optional(reviewed.parseComments) || !optional(reviewed.formatDependencyReviews)) {
|
|
110
|
+
throw new TypeError('registerReviewedDependencies: expected { lookup, parseComments?, formatDependencyReviews? }, async methods')
|
|
111
|
+
}
|
|
112
|
+
if (assembled) throw new Error('registerReviewedDependencies: call it before init(), as the server takes them as it is assembled')
|
|
113
|
+
if (registered) throw new Error('registerReviewedDependencies: reviewed dependencies are registered already')
|
|
114
|
+
registered = { lookup: reviewed.lookup.bind(reviewed), ...reviewed.parseComments ? { parseComments: reviewed.parseComments.bind(reviewed) } : {},
|
|
115
|
+
...reviewed.formatDependencyReviews ? { formatDependencyReviews: reviewed.formatDependencyReviews.bind(reviewed) } : {} }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The reviewed dependencies registered, as a server takes them once it is
|
|
119
|
+
// assembled; none registers after.
|
|
120
|
+
export function assembledReviewedDependencies(): ReviewedDependencies | null {
|
|
121
|
+
assembled = true
|
|
122
|
+
return registered
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Which of `dependencies`, by their keys, `lookup` passes, and how many it
|
|
126
|
+
// failed to answer.
|
|
127
|
+
export async function askReviewed(lookup: ReviewedDependencies['lookup'], dependencies: Map<string, ReviewedDependency>): Promise<{ passed: Set<string>; unasked: number }> {
|
|
128
|
+
const passed = new Set<string>()
|
|
129
|
+
let unasked = 0
|
|
130
|
+
const pending = [...dependencies]
|
|
131
|
+
const kept = answers.get(lookup) ?? answers.set(lookup, lru(100_000)).get(lookup)!
|
|
132
|
+
await Promise.all(Array.from({ length: Math.min(ASK_AT_ONCE, pending.length) }, async () => {
|
|
133
|
+
for (let next = pending.pop(); next; next = pending.pop()) {
|
|
134
|
+
const [key, dependency] = next
|
|
135
|
+
const known = kept.get(key)
|
|
136
|
+
let answer = known && Date.now() - known.at < KEPT_MS ? known.reviewed : null
|
|
137
|
+
if (answer === null) {
|
|
138
|
+
try { answer = await lookup({ ...dependency }) === true } catch (error) {
|
|
139
|
+
unasked++
|
|
140
|
+
console.warn('reviewed dependencies: failed to answer', `${dependency.name}@${dependency.version}`, error instanceof Error ? error.message : error)
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
kept.set(key, { at: Date.now(), reviewed: answer })
|
|
144
|
+
}
|
|
145
|
+
if (answer) passed.add(key)
|
|
146
|
+
}
|
|
147
|
+
}))
|
|
148
|
+
return { passed, unasked }
|
|
149
|
+
}
|
|
@@ -13,13 +13,15 @@ import { hashToken, randomToken } from './crypto.ts'
|
|
|
13
13
|
// `cookieName` when cookies are Secure.
|
|
14
14
|
export const STATE_COOKIE = 'dvstate'
|
|
15
15
|
|
|
16
|
-
// Base names for GitHub issue authorization state (github-issue-oauth.ts)
|
|
17
|
-
//
|
|
16
|
+
// Base names for GitHub issue authorization state (github-issue-oauth.ts),
|
|
17
|
+
// linking another GitHub account (github-link-oauth.ts) and an
|
|
18
|
+
// administrator's read-only view as another user (view-as.ts).
|
|
18
19
|
export const ISSUE_STATE_COOKIE = 'dvissuestate'
|
|
20
|
+
export const LINK_STATE_COOKIE = 'dvlinkstate'
|
|
19
21
|
export const VIEW_COOKIE = 'dvview'
|
|
20
22
|
// The session cookie cannot share a name with these, or one would overwrite
|
|
21
23
|
// or be read as the other.
|
|
22
|
-
export const RESERVED_COOKIES: readonly string[] = [STATE_COOKIE, ISSUE_STATE_COOKIE, VIEW_COOKIE]
|
|
24
|
+
export const RESERVED_COOKIES: readonly string[] = [STATE_COOKIE, ISSUE_STATE_COOKIE, LINK_STATE_COOKIE, VIEW_COOKIE]
|
|
23
25
|
|
|
24
26
|
// `__Host-`-prefix the base name when cookies are Secure (the prefix mandates
|
|
25
27
|
// Secure + Path=/ + no Domain, which `buildCookie` already emits).
|
|
@@ -55,13 +57,15 @@ export function parseCookies(header: string | undefined): Map<string, string> {
|
|
|
55
57
|
|
|
56
58
|
// Mint a session for a freshly-authenticated user. Upserts the identity, then
|
|
57
59
|
// stores the session keyed by the cookie token's hash. Returns the cookie to
|
|
58
|
-
// set and the CSRF token (also persisted on the row)
|
|
60
|
+
// set and the CSRF token (also persisted on the row), or null for an account
|
|
61
|
+
// a user linked to theirs, which can neither sign in nor register.
|
|
59
62
|
export async function createSession(
|
|
60
63
|
config: ManagedConfig, db: ManagedDb, user: ManagedUser, now: number,
|
|
61
|
-
): Promise<{ setCookie: string; csrfToken: string; userId: string }> {
|
|
64
|
+
): Promise<{ setCookie: string; csrfToken: string; userId: string } | null> {
|
|
62
65
|
const token = randomToken()
|
|
63
66
|
const csrfToken = randomToken()
|
|
64
67
|
const userId = await db.upsertUser(user, now, config.initialAdminGithubId)
|
|
68
|
+
if (userId == null) return null
|
|
65
69
|
await db.createSession({ id: hashToken(token), userId, csrfToken, expiresAt: now + config.sessionTtlMs }, now)
|
|
66
70
|
const setCookie = buildCookie(config.sessionCookieName, token, {
|
|
67
71
|
maxAgeS: Math.floor(config.sessionTtlMs / 1000), secure: config.cookieSecure, sameSite: 'Lax',
|
package/server-managed/sql.ts
CHANGED
|
@@ -23,7 +23,7 @@ const SINGLE_STATEMENTS = new Set(['getReport', 'getBundle', 'getStorageRow', 'g
|
|
|
23
23
|
'listReports', 'listBundles', 'listReadableBundleIds', 'listAllRepos', 'listSelectedRepos', 'listRepoScopesForUser', 'listTriage',
|
|
24
24
|
'getFeedState', 'getWorkspaceShareFeedState', 'claimMaintenanceLease', 'finishMaintenanceLease', 'cacheRepoDefaultBranch', 'releaseBundleBuildLease',
|
|
25
25
|
'getUpstreamCacheEntry', 'getUpstreamCacheEntries', 'setUpstreamCacheEntry', 'setUpstreamCacheEntries', 'ensureSessionUploadKey',
|
|
26
|
-
'listGithubCommits', 'listGithubCommitTags', 'setGithubCommits'])
|
|
26
|
+
'listGithubCommits', 'listGithubCommitTags', 'setGithubCommits', 'getAvatarKey', 'listUsersByAccount'])
|
|
27
27
|
// Session presence and upload keys change only their own rows.
|
|
28
28
|
const UNLOCKED_WRITES = new Set(['sessionWithUser', 'viewSessionWithUser', 'ensureSessionUploadKey'])
|
|
29
29
|
|
|
@@ -61,6 +61,11 @@ function avatarStore(objects: ObjectStorage, sidecar: boolean): AvatarStore {
|
|
|
61
61
|
if (split < 0 || split > 100) throw new Error('Invalid cached avatar')
|
|
62
62
|
return { contentType: bytes.subarray(0, split).toString(), bytes: bytes.subarray(split + 1) }
|
|
63
63
|
},
|
|
64
|
+
async delete(value) {
|
|
65
|
+
const path = `avatars/${id(value)}`
|
|
66
|
+
await objects.delete(path)
|
|
67
|
+
if (sidecar) await objects.delete(`${path}.type`)
|
|
68
|
+
},
|
|
64
69
|
}
|
|
65
70
|
}
|
|
66
71
|
|
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
// The same read names the reports a restricted reader would find nothing in:
|
|
9
9
|
// under -deps or -security the catalog leaves those out of the team's list.
|
|
10
10
|
// Readers with both permissions keep every report, empty or not.
|
|
11
|
+
//
|
|
12
|
+
// A team that is not App also says how many of its reports open in App mode
|
|
13
|
+
// on their own, and its findings for the badge it collapses to. None is as
|
|
14
|
+
// uniform as an App team, and collapses like one. A team mixing App and non-App
|
|
15
|
+
// reports stays expanded, and so does one whose reports each open as App but
|
|
16
|
+
// whose combined workspace does not (contradicting verdicts, say): it has App
|
|
17
|
+
// findings, and only expanded does it show that they fail to combine.
|
|
11
18
|
import { reportEntries } from '@preventive/report'
|
|
12
19
|
import { type TeamAppMetadata, managedTeamAppMetadata } from '../common/managed/team-app.js'
|
|
13
20
|
import type { BlobStore } from './blob-store.ts'
|
|
@@ -48,10 +55,14 @@ function remember(db: ManagedDb, key: string, summary: TeamWorkspaceSummary): Te
|
|
|
48
55
|
return summary
|
|
49
56
|
}
|
|
50
57
|
|
|
58
|
+
function isLinks(data: unknown): boolean {
|
|
59
|
+
const record = data as { source?: unknown; links?: unknown } | null
|
|
60
|
+
return record?.source === 'links' && Array.isArray(record.links)
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
// A links document counts by its links, every other report by its findings.
|
|
52
64
|
function hasContent(data: unknown): boolean {
|
|
53
|
-
|
|
54
|
-
if (record?.source === 'links' && Array.isArray(record.links)) return record.links.length > 0
|
|
65
|
+
if (isLinks(data)) return (data as { links: unknown[] }).links.length > 0
|
|
55
66
|
return (reportEntries(data) ?? []).some(entry => !Array.isArray(entry) || entry.length > 0)
|
|
56
67
|
}
|
|
57
68
|
|
|
@@ -79,7 +90,12 @@ async function classify(db: ManagedDb, store: BlobStore, key: string, team: User
|
|
|
79
90
|
}
|
|
80
91
|
const restricted = access.reports.some(report => !report.permissions.dependencies || !report.permissions.security)
|
|
81
92
|
const empty = restricted ? body.reports.filter(report => !hasContent(report.data)).map(report => report.id) : []
|
|
82
|
-
|
|
93
|
+
const links = body.links ?? []
|
|
94
|
+
const app = managedTeamAppMetadata(Object.assign(body.reports, { links }), { countFindings: true })
|
|
95
|
+
if (app.appMode) return remember(db, key, { app, empty })
|
|
96
|
+
const appReports = body.reports.filter(report => !isLinks(report.data)
|
|
97
|
+
&& managedTeamAppMetadata(Object.assign([report], { links })).appMode).length
|
|
98
|
+
return remember(db, key, { app: { ...app, appReports }, empty })
|
|
83
99
|
})().catch((error: unknown) => {
|
|
84
100
|
if (error instanceof TeamReportsError && [413, 422].includes(error.status)) return remember(db, key, NO_APP)
|
|
85
101
|
if (!(error instanceof TeamReportsError)) console.warn('managed: team App classification failed:', error)
|
|
@@ -145,15 +145,18 @@ export function workspaceShareMethods(db: ManagedSql): WorkspaceShareStore {
|
|
|
145
145
|
const links = await linkRevision(db)
|
|
146
146
|
const bundleRows = (await q.bundles.all(team.id) as (Omit<ManagedBundle, 'buildConditions'> & { buildConditions: string | null; repoFullName: string })[])
|
|
147
147
|
.map(row => ({ ...row, buildConditions: row.buildConditions == null ? null : JSON.parse(row.buildConditions) as BundleBuildConditions }))
|
|
148
|
+
const repositories = await q.repositories.all(team.id) as WorkspaceShareSnapshot['repositories']
|
|
148
149
|
return {
|
|
149
150
|
user: { id: `share:${tokenHash}`, login: 'public', name: 'Public workspace', avatarUrl: null, role: 'view' },
|
|
150
151
|
teamId: team.id,
|
|
151
152
|
linkRevision: links,
|
|
152
153
|
permissions,
|
|
153
|
-
repositories
|
|
154
|
+
repositories,
|
|
154
155
|
reports: rows.map(row => ({ id: row.id, filename: row.filename, byteSize: row.byteSize, sha256: row.sha256,
|
|
155
156
|
repo: { github: row.github, directory: row.directory }, permissions })),
|
|
156
157
|
team: { ...team, permissions, cacheKey: links,
|
|
158
|
+
repos: repositories.map(repo => ({ repoId: repo.repoId, fullName: repo.github, path: repo.path || null }))
|
|
159
|
+
.toSorted((a, b) => a.fullName.localeCompare(b.fullName) || (a.path ?? '').localeCompare(b.path ?? '')),
|
|
157
160
|
reports: rows.map(row => ({ id: row.id, slug: row.slug, filename: row.filename, visible: true, analyzer: row.analyzer, repoFullName: row.github, repoDirectory: row.directory,
|
|
158
161
|
cacheKey: JSON.stringify([row.sha256, row.github, row.directory, row.filename, permissions, links]) })),
|
|
159
162
|
bundles: bundleRows.map(row => ({ id: row.id, slug: row.slug, integrity: row.integrity,
|