@preventive/triage 1.0.0-alpha.43 → 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-compare-diff.js +2 -167
- package/common/bundle-compare-versions.js +172 -0
- package/common/bundle-comparison.d.ts +8 -0
- package/common/bundle-comparison.js +62 -0
- 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 +91 -10
- package/common/managed/team-app.d.ts +5 -2
- package/common/managed/team-app.js +2 -2
- package/common/server-info.ts +11 -0
- package/common/workspace-app.js +19 -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 -0
- 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 +31 -31
- 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 +55 -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 +13 -8
- package/risk/npm/compare.js +34 -10
- package/risk/npm/dependencies.js +11 -0
- 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/comparison.ts +41 -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 +1219 -81
- package/server-managed/index.ts +6 -2
- package/server-managed/locked-packages.ts +48 -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/pretty-print.ts +14 -9
- package/server-managed/public-workspace.ts +26 -8
- 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 +116 -106
- 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 +54 -14
- package/server-managed/workspace-shares.ts +4 -1
- package/server.ts +11 -0
- package/out/chunk-2KBF5TZ7.js +0 -2
- package/out/chunk-35VE6CDH.js +0 -2
- package/out/chunk-3BZ7JERB.js +0 -1
- package/out/chunk-4F6JSF57.js +0 -3
- package/out/chunk-4XCDFGDP.js +0 -10
- package/out/chunk-7RU5EDRO.js +0 -226
- package/out/chunk-A7M4GV7M.js +0 -1
- package/out/chunk-CLPKNNC7.js +0 -51
- package/out/chunk-CMQA4DM6.js +0 -1
- package/out/chunk-CVVJAXVY.js +0 -2
- package/out/chunk-DIJJNTRH.js +0 -10
- package/out/chunk-GJ4IUCNS.js +0 -1
- package/out/chunk-GNXOKIGP.js +0 -80
- package/out/chunk-GZ4MS3TE.js +0 -13
- 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-LAAB5SZT.js +0 -2
- package/out/chunk-NWZ4PVPN.js +0 -1
- package/out/chunk-OSIMGFUF.js +0 -1
- package/out/chunk-RFZQQXSG.js +0 -17
- package/out/chunk-RSHMVYNS.js +0 -1
- package/out/chunk-TZ5KB2Y5.js +0 -10
- package/out/chunk-UNTRGE26.js +0 -2
- package/out/chunk-UXNUAIIC.js +0 -33
- 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-VQJCXGIY.js +0 -7
- package/out/chunk-W4J3MGLB.js +0 -1
- package/out/chunk-WGG7KQUH.js +0 -1
- package/out/chunk-WZDTZE64.js +0 -1
- package/out/chunk-XOCAPDCM.js +0 -1
- package/out/chunk-XYFXXB2G.js +0 -10
- package/out/chunk-YVZH5SHP.js +0 -1
- package/out/chunk-ZBBOBQ43.js +0 -1
- package/out/chunk-ZMQQCGF6.js +0 -2
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { type LinkReportStore, linkReportMethods, linkRevision } from './link-reports.ts'
|
|
2
2
|
import { type RepositoryAliasStore, repositoryAliasMethods } from './repository-aliases.ts'
|
|
3
3
|
import { type DependencyReviewStore, dependencyReviewMethods } from './dependency-reviews.ts'
|
|
4
|
+
import { type RepositoryDependencyReviewStore, repositoryDependencyReviewMethods } from './repository-dependency-reviews.ts'
|
|
4
5
|
import { type TeamNpmScopeStore, teamNpmScopeMethods } from './team-npm-scopes.ts'
|
|
6
|
+
import { type GithubLinkStore, githubLinkMethods } from './github-links.ts'
|
|
5
7
|
import { createHash, randomUUID } from 'node:crypto'
|
|
6
8
|
import { teamCatalogRevision } from './team-catalog.ts'
|
|
7
9
|
import { type Role, roleAtLeast } from '../common/managed/roles.ts'
|
|
@@ -358,21 +360,26 @@ export interface UserTeam {
|
|
|
358
360
|
dependencyReview?: boolean
|
|
359
361
|
permissions: TeamUserPermissions
|
|
360
362
|
cacheKey?: string
|
|
363
|
+
// The team's repository links, by name: its Repositories, whether or not
|
|
364
|
+
// anything is attached to them yet.
|
|
365
|
+
repos: TeamRepoLink[]
|
|
361
366
|
reports: UserTeamReport[]
|
|
362
367
|
bundles: UserTeamBundle[]
|
|
363
368
|
}
|
|
364
369
|
|
|
365
370
|
// Backend-agnostic store surface (SQLite + PostgreSQL implementations).
|
|
366
|
-
export interface ManagedDb extends DependencyReviewStore, ActivityStore, CommentStore, GithubMetadataStore, GithubCommitStore, ManagedIssueStore, BundleBuildLeaseStore, UpstreamCacheStore, WorkspaceShareStore, ImportTriageStore, ManagementStore, ManagementCatalogStore, StorageDb, LinkReportStore, RepositoryAliasStore, TeamNpmScopeStore {
|
|
371
|
+
export interface ManagedDb extends DependencyReviewStore, RepositoryDependencyReviewStore, ActivityStore, CommentStore, GithubMetadataStore, GithubCommitStore, ManagedIssueStore, BundleBuildLeaseStore, UpstreamCacheStore, WorkspaceShareStore, ImportTriageStore, ManagementStore, ManagementCatalogStore, StorageDb, LinkReportStore, RepositoryAliasStore, TeamNpmScopeStore, GithubLinkStore {
|
|
367
372
|
claimMaintenanceLease(owner: string, now: number, until: number, migration?: boolean): Promise<boolean>
|
|
368
373
|
finishMaintenanceLease(owner: string, until: number): Promise<void>
|
|
369
374
|
getFeedState(sessionId: string, now: number): Promise<{ user: Pick<StoredUser, 'id' | 'role'>; catalog: number; annotations: number } | null>
|
|
370
375
|
// Reuse connections within a finite request or one feed polling iteration.
|
|
371
376
|
withRequest?<T>(work: () => Promise<T>): Promise<T>
|
|
372
|
-
// Upsert the identity; returns the user's opaque id (stable across logins)
|
|
377
|
+
// Upsert the identity; returns the user's opaque id (stable across logins),
|
|
378
|
+
// or null for an account linked to a user (github-links.ts), which neither
|
|
379
|
+
// signs in nor registers. A name chosen from a linked account is kept.
|
|
373
380
|
// Initial-admin approval comes only from trusted login configuration. It
|
|
374
381
|
// promotes a matching No access identity only while it is the sole user.
|
|
375
|
-
upsertUser(user: ManagedUser, now: number, initialAdminGithubId?: number | null): Promise<string>
|
|
382
|
+
upsertUser(user: ManagedUser, now: number, initialAdminGithubId?: number | null): Promise<string | null>
|
|
376
383
|
createSession(session: ManagedSession, now: number): Promise<void>
|
|
377
384
|
// A sign-in session; never a view session, so a view token in the session
|
|
378
385
|
// cookie authenticates nothing.
|
|
@@ -563,12 +570,14 @@ function prepareStatements(db: ManagedSql) {
|
|
|
563
570
|
// Identity refreshes preserve roles. Login can separately bootstrap the
|
|
564
571
|
// configured sole No access user within this same writer transaction.
|
|
565
572
|
upsertUserStmt: db.prepare(
|
|
566
|
-
`INSERT INTO managed_user (id, github_user_id, login, name, avatar_url, role, created_at, updated_at)
|
|
567
|
-
VALUES (?, ?, ?, ?, ?, 'none', ?, ?)
|
|
573
|
+
`INSERT INTO managed_user (id, github_user_id, login, name, github_name, avatar_url, role, created_at, updated_at)
|
|
574
|
+
VALUES (?, ?, ?, ?, ?, ?, 'none', ?, ?)
|
|
568
575
|
ON CONFLICT(github_user_id) DO UPDATE SET
|
|
569
|
-
login = excluded.login,
|
|
576
|
+
login = excluded.login, github_name = excluded.github_name,
|
|
577
|
+
name = CASE WHEN managed_user.name_link IS NULL THEN excluded.name ELSE managed_user.name END,
|
|
570
578
|
avatar_url = excluded.avatar_url, updated_at = excluded.updated_at`,
|
|
571
579
|
),
|
|
580
|
+
selectLinkedStmt: db.prepare(`SELECT 1 AS linked FROM managed_github_link WHERE github_user_id = ?`),
|
|
572
581
|
selectUserIdStmt: db.prepare(`SELECT id, role FROM managed_user WHERE github_user_id = ?`),
|
|
573
582
|
promoteInitialAdminStmt: db.prepare(
|
|
574
583
|
`UPDATE managed_user SET role = 'admin' WHERE id = ? AND role = 'none'
|
|
@@ -910,10 +919,15 @@ function prepareStatements(db: ManagedSql) {
|
|
|
910
919
|
renameTeamStmt: db.prepare(`UPDATE managed_team SET name = ?, updated_at = ? WHERE id = ?`),
|
|
911
920
|
setTeamHiddenStmt: db.prepare(`UPDATE managed_team SET hidden = ?, updated_at = ? WHERE id = ?`),
|
|
912
921
|
selectTeamsStmt: db.prepare(`SELECT id, slug, name, hidden FROM managed_team ORDER BY name ASC`),
|
|
922
|
+
// `dependencyReview` where the user reviews dependencies in the team
|
|
923
|
+
// (dependency-reviews.ts reviewsIn): one that reviews them, and a member
|
|
924
|
+
// who manages, or triages and may see its dependencies; an admin in any.
|
|
913
925
|
selectTeamsForUserStmt: db.prepare(
|
|
914
|
-
`SELECT t.id AS id, t.slug AS slug, t.name AS name,
|
|
926
|
+
`SELECT t.id AS id, t.slug AS slug, t.name AS name,
|
|
927
|
+
CASE WHEN t.dependency_review = 1 AND (u.role IN ('admin', 'manage') OR (u.role = 'triage' AND tu.view_dependencies = 1))
|
|
928
|
+
THEN 1 ELSE 0 END AS dependencyReview,
|
|
915
929
|
tu.view_dependencies AS dependencies, tu.view_security AS security
|
|
916
|
-
FROM managed_team_user tu JOIN managed_team t ON t.id = tu.team_id
|
|
930
|
+
FROM managed_team_user tu JOIN managed_team t ON t.id = tu.team_id JOIN managed_user u ON u.id = tu.user_id
|
|
917
931
|
WHERE tu.user_id = ? AND t.hidden = 0 ORDER BY t.name ASC, t.id ASC`,
|
|
918
932
|
),
|
|
919
933
|
selectUserTeamScopesStmt: db.prepare(
|
|
@@ -1513,6 +1527,8 @@ function teamMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql
|
|
|
1513
1527
|
cacheKey: createHash('sha256').update(JSON.stringify([
|
|
1514
1528
|
t.dependencies, t.security, links, scopes.filter(scope => scope.teamId === t.id),
|
|
1515
1529
|
])).digest('base64url'),
|
|
1530
|
+
repos: scopes.filter(scope => scope.teamId === t.id).map(scope => ({ repoId: scope.repoId, fullName: scope.fullName, path: scope.path || null }))
|
|
1531
|
+
.toSorted((a, b) => a.fullName.localeCompare(b.fullName) || (a.path ?? '').localeCompare(b.path ?? '')),
|
|
1516
1532
|
reports: reportsByTeam.get(t.id) ?? [],
|
|
1517
1533
|
bundles: bundlesByTeam.get(t.id) ?? [],
|
|
1518
1534
|
}))
|
|
@@ -1597,7 +1613,7 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
|
|
|
1597
1613
|
const reports = reportMethods(stmts, db, key)
|
|
1598
1614
|
const triage = triageMethods(stmts, options.triageHistoryLimit ?? 0, db)
|
|
1599
1615
|
const {
|
|
1600
|
-
upsertUserStmt, selectUserIdStmt, promoteInitialAdminStmt, selectGithubIdStmt, insertSessionStmt, selectOwnSessionStmt, selectUsersStmt,
|
|
1616
|
+
upsertUserStmt, selectLinkedStmt, selectUserIdStmt, promoteInitialAdminStmt, selectGithubIdStmt, insertSessionStmt, selectOwnSessionStmt, selectUsersStmt,
|
|
1601
1617
|
touchUserSeenStmt, updateRoleStmt, updateTokensStmt, selectTokensStmt, ensureUploadKeyStmt, deleteSessionStmt, deleteExpiredStmt,
|
|
1602
1618
|
selectViewSessionStmt, deleteOtherViewsStmt, insertViewSessionStmt, deleteDemotedViewsStmt,
|
|
1603
1619
|
} = stmts
|
|
@@ -1607,7 +1623,9 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
|
|
|
1607
1623
|
...linkReportMethods(db, key),
|
|
1608
1624
|
...repositoryAliasMethods(db),
|
|
1609
1625
|
...teamNpmScopeMethods(db),
|
|
1626
|
+
...githubLinkMethods(db),
|
|
1610
1627
|
...dependencyReviewMethods(db),
|
|
1628
|
+
...repositoryDependencyReviewMethods(db),
|
|
1611
1629
|
...bundleBuildLeaseMethods(db),
|
|
1612
1630
|
...upstreamCacheMethods(db),
|
|
1613
1631
|
async claimMaintenanceLease(owner, now, until, migration = false) {
|
|
@@ -1619,9 +1637,10 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
|
|
|
1619
1637
|
await db.prepare('UPDATE managed_maintenance_lease SET expires_at = ? WHERE owner = ?').run(until, owner)
|
|
1620
1638
|
},
|
|
1621
1639
|
async upsertUser(user, now, initialAdminGithubId = null) {
|
|
1640
|
+
if (await selectLinkedStmt.get(user.githubUserId)) return null
|
|
1622
1641
|
// New row → a fresh id; ON CONFLICT(github_user_id) keeps an existing
|
|
1623
1642
|
// user's id (DO UPDATE leaves it untouched), so re-read to return it.
|
|
1624
|
-
await upsertUserStmt.run(randomUUID(), user.githubUserId, user.login, user.name, user.avatarUrl, now, now)
|
|
1643
|
+
await upsertUserStmt.run(randomUUID(), user.githubUserId, user.login, user.name, user.name, user.avatarUrl, now, now)
|
|
1625
1644
|
const row = (await selectUserIdStmt.get(user.githubUserId)) as { id: string; role: Role }
|
|
1626
1645
|
// Avoid the population check unless this login matches the configured
|
|
1627
1646
|
// identity and still needs approval. NOT EXISTS stops at any other user.
|
|
@@ -2,6 +2,8 @@ import { DEPENDENCY_REVIEW_SCHEMA } from './dependency-reviews.ts'
|
|
|
2
2
|
import { LINK_REPORT_SCHEMA } from './link-reports.ts'
|
|
3
3
|
import { REPOSITORY_ALIAS_SCHEMA } from './repository-aliases.ts'
|
|
4
4
|
import { TEAM_NPM_SCOPE_SCHEMA } from './team-npm-scopes.ts'
|
|
5
|
+
import { GITHUB_LINK_SCHEMA, GITHUB_LINK_USER_COLUMNS } from './github-links.ts'
|
|
6
|
+
import { REPOSITORY_DEPENDENCY_REVIEW_COLUMNS } from './repository-dependency-reviews.ts'
|
|
5
7
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
6
8
|
import { WebSocket } from 'ws'
|
|
7
9
|
import { type ManagedDb, type ManagedDbOptions, createManagedMethods } from './db-methods.ts'
|
|
@@ -36,7 +38,7 @@ async function currentSchema(db: PgConnection): Promise<boolean> {
|
|
|
36
38
|
const exists = (await db.query("SELECT to_regclass('managed_schema_version') AS name")).rows[0]?.['name']
|
|
37
39
|
if (!exists) return false
|
|
38
40
|
const versions = new Set((await db.query('SELECT version FROM managed_schema_version')).rows.map(row => Number(row['version'])))
|
|
39
|
-
return Array.from({ length:
|
|
41
|
+
return Array.from({ length: 30 }, (_, i) => i + 1).every(version => versions.has(version))
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
async function migrateRepositoryDefaultCache(db: PgConnection): Promise<void> {
|
|
@@ -142,6 +144,31 @@ async function migrateDependencyReviews(db: PgConnection): Promise<void> {
|
|
|
142
144
|
await db.query('INSERT INTO managed_schema_version VALUES (27)')
|
|
143
145
|
}
|
|
144
146
|
|
|
147
|
+
// Existing names are the sign-in accounts' own: none was chosen yet.
|
|
148
|
+
async function migrateGithubLinks(db: PgConnection): Promise<void> {
|
|
149
|
+
if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 28')).rows.length > 0) return
|
|
150
|
+
await db.query(postgresSchema(GITHUB_LINK_SCHEMA))
|
|
151
|
+
await db.query(`ALTER TABLE managed_user ${GITHUB_LINK_USER_COLUMNS.map(([column, type]) => `ADD COLUMN IF NOT EXISTS ${column} ${type}`).join(', ')}`)
|
|
152
|
+
await db.query('UPDATE managed_user SET github_name = name')
|
|
153
|
+
await db.query('INSERT INTO managed_schema_version VALUES (28)')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function migrateRepositoryDependencyReviews(db: PgConnection): Promise<void> {
|
|
157
|
+
if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 29')).rows.length > 0) return
|
|
158
|
+
await db.query(`ALTER TABLE managed_selected_repo ${REPOSITORY_DEPENDENCY_REVIEW_COLUMNS.map(([column, type]) => `ADD COLUMN IF NOT EXISTS ${column} ${type}`).join(', ')}`)
|
|
159
|
+
await db.query('INSERT INTO managed_schema_version VALUES (29)')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The lockfiles a repository's dependency review holds to, its columns
|
|
163
|
+
// added since 29 added there too; and a team's published reviews, listed by
|
|
164
|
+
// it, indexed so.
|
|
165
|
+
async function migrateRepositoryDependencyReviewLockfiles(db: PgConnection): Promise<void> {
|
|
166
|
+
if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 30')).rows.length > 0) return
|
|
167
|
+
await db.query(`ALTER TABLE managed_selected_repo ${REPOSITORY_DEPENDENCY_REVIEW_COLUMNS.map(([column, type]) => `ADD COLUMN IF NOT EXISTS ${column} ${type}`).join(', ')}`)
|
|
168
|
+
await db.query('CREATE INDEX IF NOT EXISTS managed_dependency_review_team_idx ON managed_dependency_review_team(team_id, review_id)')
|
|
169
|
+
await db.query('INSERT INTO managed_schema_version VALUES (30)')
|
|
170
|
+
}
|
|
171
|
+
|
|
145
172
|
async function initialize(db: PgConnection): Promise<void> {
|
|
146
173
|
if (await currentSchema(db)) return
|
|
147
174
|
await db.query('BEGIN')
|
|
@@ -215,7 +242,8 @@ async function initialize(db: PgConnection): Promise<void> {
|
|
|
215
242
|
if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 11')).rows.length === 0) {
|
|
216
243
|
await db.query(postgresSchema(revisionSchema(true)) + '; INSERT INTO managed_schema_version VALUES (11)')
|
|
217
244
|
}
|
|
218
|
-
for (const migrate of [migrateRepositoryDefaultCache, migrateBundleBuildLeases, migrateBundleVisibility, migrateLinkReports, migrateRepositoryAliases, migrateHiddenTeams, migrateIssueFixes, migrateBundleProvenance, migrateUpstreamCache, migrateBundleBuildConditions, migrateSessionUploadKeys, migrateViewSessions, migrateGithubCommits, migrateTeamNpmScopes, migrateDependencyReviews
|
|
245
|
+
for (const migrate of [migrateRepositoryDefaultCache, migrateBundleBuildLeases, migrateBundleVisibility, migrateLinkReports, migrateRepositoryAliases, migrateHiddenTeams, migrateIssueFixes, migrateBundleProvenance, migrateUpstreamCache, migrateBundleBuildConditions, migrateSessionUploadKeys, migrateViewSessions, migrateGithubCommits, migrateTeamNpmScopes, migrateDependencyReviews, migrateGithubLinks, migrateRepositoryDependencyReviews,
|
|
246
|
+
migrateRepositoryDependencyReviewLockfiles]) await migrate(db)
|
|
219
247
|
await db.query('COMMIT')
|
|
220
248
|
} catch (err) {
|
|
221
249
|
await db.query('ROLLBACK')
|
|
@@ -3,6 +3,7 @@ import { LINK_REPORT_SCHEMA } from './link-reports.ts'
|
|
|
3
3
|
import { REPOSITORY_ALIAS_SCHEMA } from './repository-aliases.ts'
|
|
4
4
|
import { WORKSPACE_SHARE_SCHEMA } from './workspace-shares.ts'
|
|
5
5
|
import { TEAM_NPM_SCOPE_SCHEMA } from './team-npm-scopes.ts'
|
|
6
|
+
import { GITHUB_LINK_SCHEMA } from './github-links.ts'
|
|
6
7
|
|
|
7
8
|
// Created after the column migration, which predates it on older databases.
|
|
8
9
|
export const VIEW_SESSION_INDEX = 'CREATE INDEX IF NOT EXISTS managed_session_viewer_idx ON managed_session(viewer_session);'
|
|
@@ -64,7 +65,13 @@ CREATE TABLE IF NOT EXISTS managed_selected_repo (
|
|
|
64
65
|
added_by TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
|
|
65
66
|
added_at INTEGER NOT NULL,
|
|
66
67
|
updated_at INTEGER NOT NULL,
|
|
67
|
-
active INTEGER NOT NULL DEFAULT 1
|
|
68
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
69
|
+
-- Its pull requests' dependencies reviewed (repository-dependency-reviews.ts):
|
|
70
|
+
-- as many published reviews required of each version, and the commit at
|
|
71
|
+
-- whose lockfiles' versions none is (null for none).
|
|
72
|
+
dependency_review INTEGER NOT NULL DEFAULT 0,
|
|
73
|
+
dependency_reviews_required INTEGER NOT NULL DEFAULT 1,
|
|
74
|
+
dependency_review_since TEXT
|
|
68
75
|
) STRICT;
|
|
69
76
|
|
|
70
77
|
CREATE INDEX IF NOT EXISTS selected_repo_full_name_idx ON managed_selected_repo(full_name);
|
|
@@ -224,4 +231,4 @@ CREATE TABLE IF NOT EXISTS managed_team_user (
|
|
|
224
231
|
) STRICT;
|
|
225
232
|
|
|
226
233
|
CREATE INDEX IF NOT EXISTS team_user_user_idx ON managed_team_user(user_id);
|
|
227
|
-
` + WORKSPACE_SHARE_SCHEMA + LINK_REPORT_SCHEMA + REPOSITORY_ALIAS_SCHEMA + TEAM_NPM_SCOPE_SCHEMA + DEPENDENCY_REVIEW_SCHEMA
|
|
234
|
+
` + WORKSPACE_SHARE_SCHEMA + LINK_REPORT_SCHEMA + REPOSITORY_ALIAS_SCHEMA + TEAM_NPM_SCOPE_SCHEMA + DEPENDENCY_REVIEW_SCHEMA + GITHUB_LINK_SCHEMA
|
package/server-managed/db.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { initCommentMethods } from './comments.ts'
|
|
|
8
8
|
import { initActivityMethods } from './activity.ts'
|
|
9
9
|
import { revisionSchema } from './revisions.ts'
|
|
10
10
|
import { MANAGED_SCHEMA, VIEW_SESSION_INDEX } from './db-schema.ts'
|
|
11
|
+
import { GITHUB_LINK_USER_COLUMNS } from './github-links.ts'
|
|
12
|
+
import { REPOSITORY_DEPENDENCY_REVIEW_COLUMNS } from './repository-dependency-reviews.ts'
|
|
11
13
|
import { STORAGE_SCHEMA } from './storage-db.ts'
|
|
12
14
|
import { GITHUB_COMMIT_SCHEMA, GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
|
|
13
15
|
import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
|
|
@@ -79,6 +81,10 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
|
|
|
79
81
|
ensureColumn(db, table, 'storage_encrypted', 'INTEGER NOT NULL DEFAULT 0')
|
|
80
82
|
}
|
|
81
83
|
ensureColumn(db, 'managed_user', 'gh_tokens_encrypted', 'INTEGER NOT NULL DEFAULT 0')
|
|
84
|
+
// Existing names are the sign-in accounts' own: none was chosen yet.
|
|
85
|
+
for (const [column, type] of GITHUB_LINK_USER_COLUMNS) {
|
|
86
|
+
if (ensureColumn(db, 'managed_user', column, type) && column === 'github_name') db.exec('UPDATE managed_user SET github_name = name')
|
|
87
|
+
}
|
|
82
88
|
ensureColumn(db, 'managed_github_metadata', 'state_reason', GITHUB_STATE_REASON_COLUMN)
|
|
83
89
|
ensureColumn(db, 'managed_github_metadata', 'attempted_at', 'INTEGER')
|
|
84
90
|
// Existing public links must opt in too; never preserve an implicit grant.
|
|
@@ -116,6 +122,7 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
|
|
|
116
122
|
ensureColumn(db, 'managed_finding_issue', 'auto_fix_url', 'TEXT')
|
|
117
123
|
ensureColumn(db, 'managed_finding_issue', 'auto_fix_checked_at', 'INTEGER')
|
|
118
124
|
ensureColumn(db, 'managed_selected_repo', 'active', 'INTEGER NOT NULL DEFAULT 1')
|
|
125
|
+
for (const [column, type] of REPOSITORY_DEPENDENCY_REVIEW_COLUMNS) ensureColumn(db, 'managed_selected_repo', column, type)
|
|
119
126
|
ensureColumn(db, 'managed_finding_triage_event', 'report_id', 'TEXT')
|
|
120
127
|
ensureColumn(db, 'managed_finding_triage_event', 'report', 'TEXT')
|
|
121
128
|
ensureColumn(db, 'managed_finding_triage_event', 'repo', 'TEXT')
|
|
@@ -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
|
+
}
|