@preventive/triage 1.0.0-alpha.45 → 1.0.0-alpha.46
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/client/linked-findings.js +3 -0
- package/common/bundle-metadata.d.ts +1 -0
- package/common/bundle-metadata.js +1 -1
- package/common/bundle-sources.js +5 -1
- package/common/line-diff.js +5 -4
- package/out/{chunk-NPW52BM2.js → chunk-4HI2MSAV.js} +13 -13
- package/out/{chunk-AFLZH7WG.js → chunk-4UFAMJAX.js} +1 -1
- package/out/{chunk-HMAJDVXN.js → chunk-7XRR4OFU.js} +1 -1
- package/out/{chunk-Q6MVYGYV.js → chunk-BOIXMUU5.js} +1 -1
- package/out/{chunk-XVBEOGOS.js → chunk-CC4OM6TZ.js} +2 -2
- package/out/chunk-G24A42V6.js +2 -0
- package/out/{chunk-WI7Z5A2T.js → chunk-JKGQ3O7O.js} +1 -1
- package/out/chunk-LE5ZWLG4.js +235 -0
- package/out/chunk-MSION3G4.js +36 -0
- package/out/{chunk-6HNXNIQY.js → chunk-S6GIXWQW.js} +1 -1
- package/out/chunk-V3KWNNMY.js +1 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +25 -25
- package/out/compare.js +13 -13
- package/out/dialogs.js +40 -40
- package/out/graph.js +1 -1
- package/out/index.html +6 -6
- package/out/scan.js +2 -2
- package/out/terminal.js +1 -1
- package/out/view.css +1 -1
- package/out/view.js +15 -15
- package/package.json +3 -1
- package/risk/actions/findings.js +14 -9
- package/risk/actions/scripts/shell.js +5 -1
- package/risk/index.d.ts +5 -2
- package/server-e2e/app.ts +5 -4
- package/server-e2e/http.ts +5 -2
- package/server-managed/bundle-cache.ts +7 -1
- package/server-managed/combined.ts +3 -2
- package/server-managed/commit-lockfiles.ts +21 -11
- package/server-managed/config.ts +6 -0
- package/server-managed/db-methods.ts +37 -9
- package/server-managed/db-neon.ts +61 -21
- package/server-managed/db-schema.ts +4 -0
- package/server-managed/db.ts +2 -1
- package/server-managed/dependency-reviews.ts +13 -3
- package/server-managed/github-app.ts +12 -6
- package/server-managed/github-issue-links.ts +11 -5
- package/server-managed/github-pulls.ts +2 -2
- package/server-managed/github-requests.ts +39 -0
- package/server-managed/http-response.ts +10 -2
- package/server-managed/http.ts +195 -111
- package/server-managed/index.ts +8 -7
- package/server-managed/kept-briefly.ts +37 -0
- package/server-managed/npm-delta.ts +11 -6
- package/server-managed/npm-kept.ts +32 -2
- package/server-managed/npm-loads.ts +100 -30
- package/server-managed/npm-packages.ts +89 -3
- package/server-managed/npm-risk.ts +4 -1
- package/server-managed/pretty-print.ts +67 -23
- package/server-managed/pull-dependencies.ts +29 -9
- package/server-managed/pull-dependency-reviews.ts +8 -8
- package/server-managed/queue.ts +16 -0
- package/server-managed/rename-diff.ts +22 -13
- package/server-managed/report-sources.ts +1 -1
- package/server-managed/report-store.ts +42 -9
- package/server-managed/repository-actions.ts +15 -6
- package/server-managed/repository-browser.ts +119 -31
- package/server-managed/repository-dependency-reviews.ts +41 -19
- package/server-managed/repository-history.ts +24 -8
- package/server-managed/repository-pulse.ts +8 -1
- package/server-managed/repository-risk-audit.ts +13 -0
- package/server-managed/repository-risk-read.ts +11 -6
- package/server-managed/repository-risk.ts +27 -20
- package/server-managed/session.ts +18 -4
- package/server-managed/sql.ts +5 -2
- package/server-managed/storage.ts +4 -2
- package/server-managed/team-reports.ts +24 -12
- package/server-managed/upstream-cache.ts +12 -0
- package/server.ts +14 -5
- package/out/chunk-5DHL5NG3.js +0 -2
- package/out/chunk-NV7ES4EW.js +0 -1
- package/out/chunk-PTGDYLNS.js +0 -36
- package/out/chunk-QRD6GIG5.js +0 -229
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
1
2
|
import type { ManagedConfig } from './config.ts'
|
|
2
3
|
import type { SelectedRepo } from './db.ts'
|
|
3
|
-
import { GithubApiError, type GithubIdentity, READ_PERMISSIONS, githubJson, githubRepoPermission, githubUserIdentity, repoAccessToken } from './github-app.ts'
|
|
4
|
+
import { GithubApiError, type GithubIdentity, READ_PERMISSIONS, githubJson, githubRepoPermission, githubUserIdentity, onTokenRejected, repoAccessToken } from './github-app.ts'
|
|
4
5
|
import { MAX_PACKAGE_BYTES, readPackageEntryPoints } from './package-entry-points.ts'
|
|
5
6
|
import { readSolidityEntryPoints } from './solidity-entry-points.ts'
|
|
6
7
|
import { parseGithubCommit } from './bundle-commits.ts'
|
|
@@ -40,14 +41,63 @@ function credentialFailure(err: unknown): err is GithubApiError {
|
|
|
40
41
|
return err instanceof GithubApiError && ([401, 404].includes(err.status) || err.message === 'github-status-403')
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
//
|
|
44
|
-
//
|
|
44
|
+
// GitHub's answer on a user's access to a repository, as checkAccess reads it:
|
|
45
|
+
// the repository's metadata, which credential reads it (the user's token, the
|
|
46
|
+
// App installation's, or none, as for a public repository read anonymously)
|
|
47
|
+
// and the permission level GitHub told, where it was asked. Kept per user, by
|
|
48
|
+
// their GitHub id and token, and per repository, by its id, name and
|
|
49
|
+
// installation, for config.repositoryAccessCacheMs; only access granted is kept.
|
|
50
|
+
// One answered for a user with a token is kept no longer once GitHub rejects
|
|
51
|
+
// that token (`userToken`, hashed), whichever credential reads it: a private
|
|
52
|
+
// repository's was granted on who that token told GitHub they are. The next
|
|
53
|
+
// check asks GitHub, falling back to the installation's or none as it does.
|
|
54
|
+
type RepoMetadata = { id?: unknown; full_name?: unknown; private?: unknown; visibility?: unknown; default_branch?: unknown }
|
|
55
|
+
type AccessVerdict = { until: number; data: RepoMetadata; credential: 'user' | 'app' | 'none'; level: string | null; userToken: string | null }
|
|
56
|
+
type VerdictCache = { get(): AccessVerdict | undefined; set(verdict: Omit<AccessVerdict, 'until' | 'userToken'>): void }
|
|
57
|
+
const MAX_ACCESS_VERDICTS = 1000
|
|
58
|
+
// By the fetch reading GitHub, as installation tokens are: a test's own.
|
|
59
|
+
const accessVerdicts = new WeakMap<typeof fetch, Map<string, AccessVerdict>>()
|
|
60
|
+
const hashed = (token: string) => createHash('sha256').update(token).digest('hex')
|
|
61
|
+
onTokenRejected((token, fetchImpl) => {
|
|
62
|
+
const kept = accessVerdicts.get(fetchImpl)
|
|
63
|
+
if (!kept) return
|
|
64
|
+
const rejected = hashed(token)
|
|
65
|
+
for (const [key, verdict] of kept) if (verdict.userToken === rejected) kept.delete(key)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
function verdictCache(config: ManagedConfig, user: RepositoryBrowserUser, repo: SelectedRepo, fetchImpl: typeof fetch): VerdictCache | null {
|
|
69
|
+
const keepMs = config.repositoryAccessCacheMs
|
|
70
|
+
if (!(keepMs > 0)) return null
|
|
71
|
+
let verdicts = accessVerdicts.get(fetchImpl)
|
|
72
|
+
if (!verdicts) { verdicts = new Map(); accessVerdicts.set(fetchImpl, verdicts) }
|
|
73
|
+
const kept = verdicts
|
|
74
|
+
// The token hashed with the rest, never kept as it is.
|
|
75
|
+
const key = createHash('sha256').update(JSON.stringify([user.githubUserId, user.token, repo.repoId, repo.fullName.toLowerCase(), repo.installationId, config.githubAppId])).digest('hex')
|
|
76
|
+
return {
|
|
77
|
+
get() {
|
|
78
|
+
const verdict = kept.get(key)
|
|
79
|
+
if (verdict && verdict.until > Date.now()) return verdict
|
|
80
|
+
kept.delete(key)
|
|
81
|
+
return undefined
|
|
82
|
+
},
|
|
83
|
+
set(verdict) {
|
|
84
|
+
kept.delete(key)
|
|
85
|
+
kept.set(key, { ...verdict, until: Date.now() + keepMs, userToken: user.token ? hashed(user.token) : null })
|
|
86
|
+
while (kept.size > MAX_ACCESS_VERDICTS) kept.delete(kept.keys().next().value!)
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Create once per HTTP request. Coalesce concurrent credential lookups, and
|
|
92
|
+
// keep GitHub's answer on each user's access to each repository a while
|
|
93
|
+
// (verdictCache), never sharing credentials or answers across users.
|
|
45
94
|
export function createRepositoryBrowser(config: ManagedConfig, user: RepositoryBrowserUser, fetchImpl: typeof fetch = globalThis.fetch) {
|
|
46
95
|
const tokens = new Map<number, Promise<string | null>>()
|
|
47
96
|
let identity: Promise<GithubIdentity> | undefined
|
|
48
|
-
|
|
97
|
+
// The user as GitHub tells them, asked once a request.
|
|
98
|
+
const getIdentity = (): Promise<GithubIdentity> => {
|
|
49
99
|
if (!user.token || user.githubUserId == null) return Promise.reject(new GithubApiError(404, 'no-repository'))
|
|
50
|
-
if (!identity
|
|
100
|
+
if (!identity) {
|
|
51
101
|
identity = githubUserIdentity(user.token, fetchImpl).then(value => {
|
|
52
102
|
if (value.id !== user.githubUserId) throw new GithubApiError(404, 'no-repository')
|
|
53
103
|
return value
|
|
@@ -71,7 +121,7 @@ export function createRepositoryBrowser(config: ManagedConfig, user: RepositoryB
|
|
|
71
121
|
// Its Dependabot alerts, with the token of the installation GitHub tells
|
|
72
122
|
// covers it, where the user's own access lets them see them
|
|
73
123
|
// (dependabot-access.ts).
|
|
74
|
-
return repositoryReader(repo, user.token, installationToken, getIdentity, fetchImpl).then(reader => ({ ...reader,
|
|
124
|
+
return repositoryReader(repo, user.token, installationToken, getIdentity, fetchImpl, verdictCache(config, user, repo, fetchImpl)).then(reader => ({ ...reader,
|
|
75
125
|
dependabotAlerts: () => appDependabotAlerts(config, repo, { identity: getIdentity, permission: reader.permission }, fetchImpl) }))
|
|
76
126
|
},
|
|
77
127
|
}
|
|
@@ -96,48 +146,86 @@ export async function repositoryTreeFiles(fullName: string, token: string | null
|
|
|
96
146
|
return { files, bytes }
|
|
97
147
|
}
|
|
98
148
|
|
|
99
|
-
|
|
149
|
+
type Access = { data: RepoMetadata; token: string | null; level: string | null }
|
|
150
|
+
|
|
151
|
+
// GitHub asked whether the user may read `repo`: its metadata, read with
|
|
152
|
+
// `token` (the reader's credential so far), else the installation's, else
|
|
153
|
+
// none; and of one not public, the user's permission, read with the
|
|
154
|
+
// installation's. What it told, the token reading it, and the level asked.
|
|
155
|
+
async function askAccess(repo: SelectedRepo, start: string | null, userToken: string | null, installationToken: () => Promise<string | null>,
|
|
156
|
+
getIdentity: () => Promise<GithubIdentity>, fetchImpl: typeof fetch): Promise<Access> {
|
|
100
157
|
const base = `https://api.github.com/repos/${repo.fullName.split('/').map(encodeURIComponent).join('/')}`
|
|
101
|
-
|
|
102
|
-
// Nonpublic reads still require the repository App and the effective-user gate,
|
|
103
|
-
// whose permission level is kept (`level`).
|
|
104
|
-
let level: string | null = null, token = userToken ?? await installationToken()
|
|
105
|
-
const read = (suffix: string) => githubJson(base + suffix, token, fetchImpl)
|
|
158
|
+
let token = start
|
|
106
159
|
const metadata = async () => {
|
|
107
|
-
try { return await
|
|
160
|
+
try { return await githubJson(base, token, fetchImpl) }
|
|
108
161
|
catch (err) {
|
|
109
162
|
if (!token || !credentialFailure(err)) throw err
|
|
110
163
|
const fallback = await installationToken()
|
|
111
164
|
if (fallback && fallback !== token) {
|
|
112
165
|
token = fallback
|
|
113
|
-
try { return await
|
|
166
|
+
try { return await githubJson(base, token, fetchImpl) } catch (fallbackError) { if (!credentialFailure(fallbackError)) throw fallbackError }
|
|
114
167
|
}
|
|
115
168
|
// Anonymous fallback is only for unavailable credentials, never rate
|
|
116
169
|
// limits, network errors, or GitHub outages.
|
|
117
170
|
token = null
|
|
118
|
-
return
|
|
171
|
+
return githubJson(base, token, fetchImpl)
|
|
119
172
|
}
|
|
120
173
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
174
|
+
// A repository last known private asks who the user is as it asks for its
|
|
175
|
+
// metadata, not after: its permission is asked of them.
|
|
176
|
+
if (repo.private && userToken) getIdentity().catch(() => {})
|
|
177
|
+
// Stored visibility can be stale (or internal with private=false). Require
|
|
178
|
+
// current, explicit public visibility or the caller's effective permission.
|
|
179
|
+
const data = await metadata() as RepoMetadata
|
|
180
|
+
if (data?.id !== repo.repoId || typeof data.full_name !== 'string' || data.full_name.toLowerCase() !== repo.fullName.toLowerCase()) {
|
|
181
|
+
throw new GithubApiError(404, 'no-repository')
|
|
182
|
+
}
|
|
183
|
+
if (data.private === false && data.visibility === 'public') return { data, token, level: null }
|
|
184
|
+
if (!token) throw new GithubApiError(404, 'no-repository')
|
|
185
|
+
const appToken = await installationToken()
|
|
186
|
+
if (!appToken) throw new GithubApiError(404, 'no-repository')
|
|
187
|
+
const level = await githubRepoPermission(repo.fullName, appToken, await getIdentity(), fetchImpl)
|
|
188
|
+
if (!READ_PERMISSIONS.has(level)) throw new GithubApiError(404, 'no-repository')
|
|
189
|
+
return { data, token: appToken, level }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// GitHub's answer as kept (verdictCache), with the credential it chose,
|
|
193
|
+
// where that credential is still had; else asked (`ask`), and kept where it
|
|
194
|
+
// grants access.
|
|
195
|
+
async function keptAccess(verdicts: VerdictCache | null, userToken: string | null, installationToken: () => Promise<string | null>,
|
|
196
|
+
ask: () => Promise<Access>): Promise<Access> {
|
|
197
|
+
const kept = verdicts?.get()
|
|
198
|
+
if (kept) {
|
|
199
|
+
const credential = kept.credential === 'user' ? userToken : kept.credential === 'app' ? await installationToken() : null
|
|
200
|
+
if (kept.credential === 'none' || credential) return { data: kept.data, token: credential, level: kept.level }
|
|
201
|
+
}
|
|
202
|
+
const asked = await ask()
|
|
203
|
+
const { id, full_name, private: hidden, visibility, default_branch } = asked.data
|
|
204
|
+
const data = { id, full_name, private: hidden, visibility, default_branch }
|
|
205
|
+
verdicts?.set({ data, credential: asked.token === null ? 'none' : asked.token === userToken ? 'user' : 'app', level: asked.level })
|
|
206
|
+
return { ...asked, data }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function repositoryReader(repo: SelectedRepo, userToken: string | null, installationToken: () => Promise<string | null>, getIdentity: (fresh?: boolean) => Promise<GithubIdentity>, fetchImpl: typeof fetch,
|
|
210
|
+
verdicts: VerdictCache | null) {
|
|
211
|
+
const base = `https://api.github.com/repos/${repo.fullName.split('/').map(encodeURIComponent).join('/')}`
|
|
212
|
+
// Public reads can use the signed-in user's quota without repository grants.
|
|
213
|
+
// Nonpublic reads still require the repository App and the effective-user gate,
|
|
214
|
+
// whose permission level is kept (`level`).
|
|
215
|
+
let level: string | null = null, token = userToken ?? await installationToken()
|
|
216
|
+
const read = (suffix: string) => githubJson(base + suffix, token, fetchImpl)
|
|
217
|
+
const checkAccess = async (): Promise<RepoMetadata> => {
|
|
218
|
+
const access = await keptAccess(verdicts, userToken, installationToken, () => askAccess(repo, token, userToken, installationToken, getIdentity, fetchImpl))
|
|
219
|
+
token = access.token
|
|
220
|
+
level = access.level
|
|
221
|
+
return access.data
|
|
136
222
|
}
|
|
137
223
|
const current = await checkAccess()
|
|
138
224
|
const defaultBranch = typeof current.default_branch === 'string' ? current.default_branch : ''
|
|
139
225
|
return {
|
|
140
|
-
|
|
226
|
+
// Its visibility and the user's permission asked again, the user as
|
|
227
|
+
// GitHub told them earlier in the request, not asked again.
|
|
228
|
+
recheckAccess: () => checkAccess(),
|
|
141
229
|
// Server-only credential, selected by the same live visibility/permission
|
|
142
230
|
// gate as directory browsing. Never included in an HTTP response.
|
|
143
231
|
readToken: () => token,
|
|
@@ -38,8 +38,13 @@ export interface PublishedReview {
|
|
|
38
38
|
// published reviews of the packages asked.
|
|
39
39
|
export interface RepoReviewState extends Omit<RepoDependencyReview, 'enabled'> { canReview: boolean; reviews: PublishedReview[] }
|
|
40
40
|
|
|
41
|
+
// A package version whose published reviews are asked.
|
|
42
|
+
export interface ReviewedVersionKey { name: string; version: string }
|
|
43
|
+
|
|
41
44
|
export interface RepositoryDependencyReviewStore {
|
|
42
45
|
listRepoDependencyReviews(): Promise<Map<number, RepoDependencyReview>>
|
|
46
|
+
// One repository's, none where it isn't connected.
|
|
47
|
+
repoDependencyReview(repoId: number): Promise<RepoDependencyReview | null>
|
|
43
48
|
// The teams whose published reviews count for each repository (COUNTING_TEAM),
|
|
44
49
|
// by name.
|
|
45
50
|
listRepoReviewTeams(): Promise<Map<number, { id: string; name: string }[]>>
|
|
@@ -47,7 +52,9 @@ export interface RepositoryDependencyReviewStore {
|
|
|
47
52
|
// `since` or `lockfiles` is undefined, which keeps it; the setting then, or
|
|
48
53
|
// null for no repository.
|
|
49
54
|
setRepoDependencyReview(repoId: number, setting: Omit<RepoDependencyReview, 'since' | 'lockfiles'> & { since?: string | null; lockfiles?: LockfileScope }): Promise<RepoDependencyReview | null>
|
|
50
|
-
|
|
55
|
+
// Its review in effect for `user`, as `setting` tells it where read
|
|
56
|
+
// already, with the published reviews of `versions`.
|
|
57
|
+
repoDependencyReviewState(repoId: number, user: string, versions: ReviewedVersionKey[], setting?: RepoDependencyReview | null): Promise<RepoReviewState | null>
|
|
51
58
|
// Which of `users` publish reviews to a team counting for repository
|
|
52
59
|
// `repoId`, whose reviews its pull requests' comments make count.
|
|
53
60
|
listRepoReviewers(repoId: number, users: string[]): Promise<Set<string>>
|
|
@@ -59,33 +66,42 @@ const setting = (row: SettingRow): RepoDependencyReview => ({ enabled: row.enabl
|
|
|
59
66
|
// dependencies and have it, not hidden.
|
|
60
67
|
const COUNTING_TEAM = `t.hidden = 0 AND t.dependency_review = 1 AND EXISTS (
|
|
61
68
|
SELECT 1 FROM managed_team_repo tr WHERE tr.team_id = t.id AND tr.repo_id = ?)`
|
|
62
|
-
const
|
|
69
|
+
const VERSIONS_AT_ONCE = 250
|
|
63
70
|
|
|
64
|
-
// The published and unrevoked reviews of `
|
|
65
|
-
// repository `repoId`, each with whether it is full, a
|
|
66
|
-
// a query
|
|
67
|
-
|
|
71
|
+
// The published and unrevoked reviews of `versions` in teams counting for
|
|
72
|
+
// repository `repoId`, each with whether it is full, a couple of hundred
|
|
73
|
+
// versions a query: those of their packages' and versions' found, the
|
|
74
|
+
// versions asked alone kept, their files' reviews read of those alone.
|
|
75
|
+
async function publishedReviews(db: ManagedSql, repoId: number, versions: ReviewedVersionKey[]): Promise<PublishedReview[]> {
|
|
68
76
|
const found: PublishedReview[] = []
|
|
69
|
-
for (let at = 0; at <
|
|
70
|
-
const
|
|
71
|
-
const
|
|
77
|
+
for (let at = 0; at < versions.length; at += VERSIONS_AT_ONCE) {
|
|
78
|
+
const asked = versions.slice(at, at + VERSIONS_AT_ONCE)
|
|
79
|
+
const keys = new Set(asked.map(({ name, version }) => `${name}@${version}`))
|
|
80
|
+
const [names, numbers] = [[...new Set(asked.map(({ name }) => name))], [...new Set(asked.map(({ version }) => version))]]
|
|
81
|
+
const rows = (await db.prepare(`SELECT r.id, r.user_id AS userId, r.user_login AS userLogin, r.package, r.version, r.integrity,
|
|
72
82
|
NULLIF(r.base_package, '') AS basePackage, NULLIF(r.base_version, '') AS baseVersion, r.files, r.status, r.published_at AS publishedAt
|
|
73
83
|
FROM managed_dependency_review r
|
|
74
84
|
WHERE r.ecosystem = 'npm' AND r.published_at IS NOT NULL AND r.revoked_at IS NULL AND r.status IS NOT NULL
|
|
75
|
-
AND r.package IN (${names.map(() => '?').join(',')})
|
|
85
|
+
AND r.package IN (${names.map(() => '?').join(',')}) AND r.version IN (${numbers.map(() => '?').join(',')})
|
|
76
86
|
AND EXISTS (SELECT 1 FROM managed_dependency_review_team rt JOIN managed_team t ON t.id = rt.team_id
|
|
77
|
-
WHERE rt.review_id = r.id AND ${COUNTING_TEAM})`).all(...names, repoId) as
|
|
87
|
+
WHERE rt.review_id = r.id AND ${COUNTING_TEAM})`).all(...names, ...numbers, repoId) as
|
|
78
88
|
{ id: string; userId: string | null; userLogin: string; package: string; version: string; integrity: string
|
|
79
|
-
basePackage: string | null; baseVersion: string | null; files: string | null; status: ReviewVerdict; publishedAt: number }[]
|
|
89
|
+
basePackage: string | null; baseVersion: string | null; files: string | null; status: ReviewVerdict; publishedAt: number }[])
|
|
90
|
+
.filter(row => keys.has(`${row.package}@${row.version}`))
|
|
80
91
|
if (rows.length === 0) continue
|
|
81
|
-
const entries =
|
|
82
|
-
|
|
92
|
+
const entries = new Map<string, { file: string; status: ReviewVerdict | null }[]>()
|
|
93
|
+
for (const { reviewId, ...entry } of await db.prepare(`SELECT review_id AS reviewId, file, status FROM managed_dependency_file_review
|
|
94
|
+
WHERE review_id IN (${rows.map(() => '?').join(',')})`).all(...rows.map(row => row.id)) as { reviewId: string; file: string; status: ReviewVerdict | null }[]) {
|
|
95
|
+
const of = entries.get(reviewId)
|
|
96
|
+
if (of) of.push(entry)
|
|
97
|
+
else entries.set(reviewId, [entry])
|
|
98
|
+
}
|
|
83
99
|
for (const row of rows) {
|
|
84
100
|
const snapshot = row.files === null ? null : JSON.parse(row.files) as string[] | { paths: string[]; nonTextFiles: string[] }
|
|
85
101
|
// A snapshot of paths alone, before non-text files were told, can't be full.
|
|
86
102
|
const files = Array.isArray(snapshot) ? snapshot : snapshot?.paths ?? null
|
|
87
103
|
const nonText = Array.isArray(snapshot) ? null : snapshot?.nonTextFiles ?? []
|
|
88
|
-
const full = summarizeReview(entries.
|
|
104
|
+
const full = summarizeReview(entries.get(row.id) ?? [], files, nonText).coverage === 'full'
|
|
89
105
|
found.push({ id: row.id, package: row.package, version: row.version, integrity: row.integrity, basePackage: row.basePackage, baseVersion: row.baseVersion,
|
|
90
106
|
reviewer: row.userId ?? `login:${row.userLogin.toLowerCase()}`, status: row.status, full, time: Number(row.publishedAt) })
|
|
91
107
|
}
|
|
@@ -96,11 +112,16 @@ async function publishedReviews(db: ManagedSql, repoId: number, packages: string
|
|
|
96
112
|
export function repositoryDependencyReviewMethods(db: ManagedSql): RepositoryDependencyReviewStore {
|
|
97
113
|
const columns = `repo_id AS repoId, dependency_review AS enabled, dependency_reviews_required AS required, dependency_review_since AS since,
|
|
98
114
|
dependency_review_lockfiles AS lockfiles`
|
|
115
|
+
const readSetting = async (repoId: number) => {
|
|
116
|
+
const row = await db.prepare(`SELECT ${columns} FROM managed_selected_repo WHERE repo_id = ?`).get(repoId) as SettingRow | undefined
|
|
117
|
+
return row ? setting(row) : null
|
|
118
|
+
}
|
|
99
119
|
return {
|
|
100
120
|
async listRepoDependencyReviews() {
|
|
101
121
|
const rows = await db.prepare(`SELECT ${columns} FROM managed_selected_repo`).all() as SettingRow[]
|
|
102
122
|
return new Map(rows.map(row => [row.repoId, setting(row)]))
|
|
103
123
|
},
|
|
124
|
+
repoDependencyReview: readSetting,
|
|
104
125
|
async listRepoReviewTeams() {
|
|
105
126
|
const rows = await db.prepare(`SELECT DISTINCT tr.repo_id AS repoId, t.id AS id, t.name AS name FROM managed_team_repo tr
|
|
106
127
|
JOIN managed_team t ON t.id = tr.team_id WHERE t.hidden = 0 AND t.dependency_review = 1 ORDER BY t.name, t.id`).all() as { repoId: number; id: string; name: string }[]
|
|
@@ -123,13 +144,14 @@ export function repositoryDependencyReviewMethods(db: ManagedSql): RepositoryDep
|
|
|
123
144
|
WHERE u.id IN (${users.map(() => '?').join(',')}) AND ${reviewsIn(true)} AND ${COUNTING_TEAM}`).all(...users, repoId) as { id: string }[]
|
|
124
145
|
return new Set(rows.map(row => row.id))
|
|
125
146
|
},
|
|
126
|
-
async repoDependencyReviewState(repoId, user,
|
|
127
|
-
const
|
|
128
|
-
if (!
|
|
147
|
+
async repoDependencyReviewState(repoId, user, versions, read) {
|
|
148
|
+
const now = read === undefined ? await readSetting(repoId) : read
|
|
149
|
+
if (!now?.enabled) return null
|
|
129
150
|
if (!await db.prepare(`SELECT 1 FROM managed_team t WHERE ${COUNTING_TEAM}`).get(repoId)) return null
|
|
130
151
|
const canReview = await db.prepare(`SELECT 1 FROM managed_team t JOIN managed_user u ON u.id = ?
|
|
131
152
|
WHERE ${reviewsIn(true)} AND ${COUNTING_TEAM}`).get(user, repoId) != null
|
|
132
|
-
|
|
153
|
+
const asked = [...new Map(versions.map(version => [`${version.name}@${version.version}`, version])).values()]
|
|
154
|
+
return { required: now.required, since: now.since, lockfiles: now.lockfiles, canReview, reviews: await publishedReviews(db, repoId, asked) }
|
|
133
155
|
},
|
|
134
156
|
}
|
|
135
157
|
}
|
|
@@ -122,7 +122,7 @@ const TIMED_OUT = /\btimeout\b/iu
|
|
|
122
122
|
// are logged, those read past too.
|
|
123
123
|
export async function githubGraphql(query: string, variables: Record<string, unknown>, token: string, fetchImpl: typeof fetch,
|
|
124
124
|
tolerated: (error: GraphqlError) => boolean = () => false) {
|
|
125
|
-
const body = await githubJson('https://api.github.com/graphql', token, fetchImpl, 'POST', { query, variables }) as { data?: unknown; errors?: GraphqlError[] } | null
|
|
125
|
+
const body = await githubJson('https://api.github.com/graphql', token, fetchImpl, 'POST', { query, variables }, true) as { data?: unknown; errors?: GraphqlError[] } | null
|
|
126
126
|
const all = Array.isArray(body?.errors) ? body.errors : []
|
|
127
127
|
const unnamed = all.filter(error => !NAMED_ERRORS.has(String(error?.type)))
|
|
128
128
|
if (unnamed.length > 0) {
|
|
@@ -172,6 +172,9 @@ const GLUE_LOOKUP = 50
|
|
|
172
172
|
// changes.
|
|
173
173
|
export interface HistoryPages {
|
|
174
174
|
read(tops: string[]): Promise<Map<string, HistoryCommit[]>>
|
|
175
|
+
// The pages kept of `first`; where none is, those of `then`, in one read
|
|
176
|
+
// (`first` whether they are `first`'s). Without it, both are read together.
|
|
177
|
+
readFirst?(first: string[], then: string[]): Promise<{ first: boolean; pages: Map<string, HistoryCommit[]> }>
|
|
175
178
|
write(top: string, commits: HistoryCommit[]): Promise<void>
|
|
176
179
|
}
|
|
177
180
|
|
|
@@ -241,8 +244,11 @@ const graphCommit = (node: GraphCommit | null | undefined) => historyCommit(node
|
|
|
241
244
|
|
|
242
245
|
// The history from each of `starts`, a page of each, kept (`pages`), or read
|
|
243
246
|
// and then kept by its top; glued on to it, the pages kept that start at a
|
|
244
|
-
// parent of a commit listed not listed,
|
|
245
|
-
//
|
|
247
|
+
// parent of a commit listed not listed, each adding commits none listed has,
|
|
248
|
+
// and, where any of those has none kept and readable, at a commit listed, the
|
|
249
|
+
// oldest first (where each goes on furthest; a page at a commit listed lists
|
|
250
|
+
// mostly commits listed already), GLUE_LOOKUP of each looked up at a time, in
|
|
251
|
+
// one read where none of those parents' is kept, else two, and
|
|
246
252
|
// those that start where those go on, till HISTORY_GLUED are listed. The
|
|
247
253
|
// pages from `starts` are listed whole, however many that is (all branches'
|
|
248
254
|
// first, as many as ALL_BRANCHES of them): the cap is gluing's. Each commit
|
|
@@ -259,11 +265,21 @@ export async function historyFrom(fullName: string, token: string | null, starts
|
|
|
259
265
|
for (const top of starts) add(kept.get(top) ?? read.get(top) ?? [])
|
|
260
266
|
const looked = new Set(starts)
|
|
261
267
|
for (let round = 0; round < MAX_GLUE_ROUNDS && listed.size < HISTORY_GLUED; round++) {
|
|
262
|
-
const parents = [...listed.values()].flatMap(commit => commit.parents.filter(parent => !listed.has(parent)))
|
|
263
|
-
const
|
|
264
|
-
if (
|
|
265
|
-
|
|
266
|
-
const
|
|
268
|
+
const parents = [...new Set([...listed.values()].flatMap(commit => commit.parents.filter(parent => !listed.has(parent) && !looked.has(parent))))].slice(0, GLUE_LOOKUP)
|
|
269
|
+
const within = [...listed.keys()].toReversed().filter(sha => !looked.has(sha)).slice(0, GLUE_LOOKUP)
|
|
270
|
+
if (parents.length === 0 && within.length === 0) break
|
|
271
|
+
// Without readFirst, GLUE_LOOKUP of both at once, the parents first.
|
|
272
|
+
const asked = pages.readFirst ? null : [...parents, ...within].slice(0, GLUE_LOOKUP)
|
|
273
|
+
let { first, pages: glued } = asked ? { first: false, pages: await pages.read(asked) } : await pages.readFirst!(parents, within)
|
|
274
|
+
// A parent with no page kept, or none readable (one GitHub's REST API
|
|
275
|
+
// listed, read with GraphQL), its line going on only by a page at a
|
|
276
|
+
// commit listed: those read too, apart, the others' lines notwithstanding.
|
|
277
|
+
if (first && glued.size < parents.length && within.length > 0) {
|
|
278
|
+
glued = new Map([...glued, ...await pages.read(within)])
|
|
279
|
+
first = false
|
|
280
|
+
}
|
|
281
|
+
// Those at commits listed, unread where a parent's answered, are looked up again.
|
|
282
|
+
for (const sha of asked ?? (first ? parents : [...parents, ...within])) looked.add(sha)
|
|
267
283
|
for (const commit of [...glued.values()].flat()) {
|
|
268
284
|
if (listed.size >= HISTORY_GLUED) break
|
|
269
285
|
if (!listed.has(commit.sha)) listed.set(commit.sha, commit)
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// issues closed and opened, how many and the latest of each.
|
|
15
15
|
import { agentOperated, githubAgent } from '../common/managed/github-agents.ts'
|
|
16
16
|
import { GithubApiError, githubJson } from './github-app.ts'
|
|
17
|
+
import { shareGithubRead } from './github-requests.ts'
|
|
17
18
|
import { ACTOR, type GraphAccount, type HistoryCommit, type HistoryPages, accountOf, actorOf, githubGraphql, historyFrom } from './repository-history.ts'
|
|
18
19
|
|
|
19
20
|
export const PULSE_WEEKS = [1, 4, 13, 52]
|
|
@@ -99,7 +100,13 @@ const emptyPunch = () => Array.from({ length: 7 }, () => Array.from({ length: 24
|
|
|
99
100
|
// One statistic: its rows once GitHub has computed it, 'pending' while it
|
|
100
101
|
// computes them (202), none for a repository with no commits (204, no body),
|
|
101
102
|
// 'too-large' where it counts none (lines past 10,000 commits, 422).
|
|
102
|
-
|
|
103
|
+
function statistic(fullName: string, token: string | null, kind: string, fetchImpl: typeof fetch) {
|
|
104
|
+
// Stats and lines both need contributors. Share by the original transport;
|
|
105
|
+
// the per-call status wrapper below would otherwise give each its own read.
|
|
106
|
+
return shareGithubRead(fetchImpl, JSON.stringify(['statistic', fullName, token, kind]), () => readStatistic(fullName, token, kind, fetchImpl))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function readStatistic(fullName: string, token: string | null, kind: string, fetchImpl: typeof fetch): Promise<unknown[] | 'pending' | 'too-large'> {
|
|
103
110
|
let status = 0
|
|
104
111
|
const answered: typeof fetch = async (input, init) => {
|
|
105
112
|
const res = await fetchImpl(input, init)
|
|
@@ -33,6 +33,19 @@ const AUDIT_FAILED_KEPT_MS = 10 * 60_000
|
|
|
33
33
|
// RISK_KEPT_MS, why they couldn't be had within AUDIT_FAILED_KEPT_MS.
|
|
34
34
|
const isFresh = ({ at, audit }: { at: number; audit: Audit }) => Date.now() - at < (Array.isArray(audit) ? RISK_KEPT_MS : AUDIT_FAILED_KEPT_MS)
|
|
35
35
|
|
|
36
|
+
// The audits of lockfiles, each of its `packages` by its `key` (auditLockfile),
|
|
37
|
+
// `inTurn` with others, asked at once: each null where it has none to ask
|
|
38
|
+
// of, or once `signal` aborts. Each is the caller's to tell once it may.
|
|
39
|
+
export function auditLockfiles(source: AuditSource, memory: Lru<{ at: number; audit: Audit }>, signal: AbortSignal,
|
|
40
|
+
lockfiles: { key: string; packages: readonly LockedPackage[] | null }[], inTurn: <T>(work: () => Promise<T>) => Promise<T>): Promise<Audit | null>[] {
|
|
41
|
+
return lockfiles.map(({ key, packages }) => {
|
|
42
|
+
const audit = packages ? inTurn(() => (signal.aborted ? Promise.resolve(null) : auditLockfile(source, memory, signal, key, packages))) : Promise.resolve(null)
|
|
43
|
+
// One no longer waited on, as the scan stopped, fails alone.
|
|
44
|
+
audit.catch(() => {})
|
|
45
|
+
return audit
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
// The audit of a lockfile's `packages`, by its `key` as kept (`memory`, then
|
|
37
50
|
// the source's), else asked and kept; null where it locks none to ask of, or
|
|
38
51
|
// once `signal` aborts.
|
|
@@ -16,6 +16,7 @@ import type { DependabotAlert } from './dependabot-alerts.ts'
|
|
|
16
16
|
import { type LockedPackage, isTestFixture, lockedPackages } from './locked-packages.ts'
|
|
17
17
|
import type { Lru } from './lru.ts'
|
|
18
18
|
import type { KeptStore } from './npm-kept.ts'
|
|
19
|
+
import { inTurns } from './queue.ts'
|
|
19
20
|
import { type WorkflowsRead, lastingRead, readActions } from './repository-actions.ts'
|
|
20
21
|
import { type RiskFailure, failureOf } from './repository-risk-assess.ts'
|
|
21
22
|
import type { RepositoryRiskLine, RepositoryRiskRepo } from './repository-risk.ts'
|
|
@@ -28,6 +29,9 @@ const READ_FORMATS: ReadonlySet<LockfileFormat> = new Set(['npm', 'pnpm', 'yarn'
|
|
|
28
29
|
// How long the source's `listings` and `lockfiles` are read for: what
|
|
29
30
|
// GitHub has at a commit or a blob doesn't change, how it is read here may.
|
|
30
31
|
export const SCAN_READ_KEPT_MS = 24 * 60 * 60_000
|
|
32
|
+
// How many of a repository's lockfiles are read at once: each one GitHub is
|
|
33
|
+
// asked for where it isn't kept, then parsed.
|
|
34
|
+
const LOCKFILES_AT_ONCE = 4
|
|
31
35
|
|
|
32
36
|
export type ListedLockfile = Pick<RepoLockfile, 'path' | 'format' | 'sha'>
|
|
33
37
|
export type LockfileRead = { packages: LockedPackage[] } | RiskFailure
|
|
@@ -140,8 +144,8 @@ async function readDependabot(opened: OpenedRepository, paths: readonly string[]
|
|
|
140
144
|
}
|
|
141
145
|
}
|
|
142
146
|
|
|
143
|
-
// A repository read: its lockfiles listed and read,
|
|
144
|
-
// asked and its Actions read meanwhile; its line telling why where listing
|
|
147
|
+
// A repository read: its lockfiles listed and read, a few at once, its
|
|
148
|
+
// Dependabot alerts asked and its Actions read meanwhile; its line telling why where listing
|
|
145
149
|
// failed; null where the scan stopped. A test's
|
|
146
150
|
// fixture or example its reader refuses (isTestFixture) is left out, as a
|
|
147
151
|
// test may keep one so on purpose.
|
|
@@ -153,11 +157,12 @@ export async function readRepository<R extends RepositoryRiskRepo>(reading: Repo
|
|
|
153
157
|
const acting = workflows ? keptRead(reading.caches.actionFiles, reading.source.actionFiles, `${repo.repoId}@${opened.commit}`,
|
|
154
158
|
() => readActions({ workflows, file: path => opened.file(path) }), lastingRead) : Promise.resolve(null)
|
|
155
159
|
acting.catch(() => {})
|
|
160
|
+
const inTurn = inTurns(LOCKFILES_AT_ONCE)
|
|
161
|
+
const lines = await Promise.all(listed.lockfiles.map(lockfile => inTurn(() => (reading.signal.aborted ? Promise.resolve(null) : readLockfile(reading, repo.repoId, opened, lockfile)))))
|
|
156
162
|
const lockfiles: RepositoryRead['lockfiles'] = []
|
|
157
|
-
for (const
|
|
158
|
-
if (
|
|
159
|
-
|
|
160
|
-
if (!('error' in line && line.error === 'lockfile-unreadable' && isTestFixture(line.path))) lockfiles.push({ sha: lockfile.sha, line })
|
|
163
|
+
for (const [at, line] of lines.entries()) {
|
|
164
|
+
if (!line) return null
|
|
165
|
+
if (!('error' in line && line.error === 'lockfile-unreadable' && isTestFixture(line.path))) lockfiles.push({ sha: listed.lockfiles[at]!.sha, line })
|
|
161
166
|
}
|
|
162
167
|
const dependabot = await alerting
|
|
163
168
|
const actions = await acting
|
|
@@ -34,10 +34,10 @@ import { type Lru, lru } from './lru.ts'
|
|
|
34
34
|
import type { KeptStore } from './npm-kept.ts'
|
|
35
35
|
import { MAX_SPARE_ASSESSMENTS } from './npm-risk.ts'
|
|
36
36
|
import type { LockedPackage } from './locked-packages.ts'
|
|
37
|
-
import { type Queue, queue } from './queue.ts'
|
|
37
|
+
import { type Queue, inTurns, queue } from './queue.ts'
|
|
38
38
|
import { type DownloadsSource, readyingDownloads } from './scan-downloads.ts'
|
|
39
39
|
import { type AssessedPackage, type Assessment, BUSY_RETRIES, BUSY_WAIT_MS, RISK_KEPT_MS, type RiskFailure, assessPackage, failureOf, isBusy } from './repository-risk-assess.ts'
|
|
40
|
-
import { type AdvisoryRow, type Audit, type AuditSource,
|
|
40
|
+
import { type AdvisoryRow, type Audit, type AuditSource, auditLockfiles } from './repository-risk-audit.ts'
|
|
41
41
|
import { type DependabotRead, type ListedLockfile, type LockfileRead, type OpenedRepository, type RepositoryFailure, type RepositoryRead, SCAN_READ_KEPT_MS,
|
|
42
42
|
readRepository } from './repository-risk-read.ts'
|
|
43
43
|
import { type DependabotAlert, alertVersions } from './dependabot-alerts.ts'
|
|
@@ -157,6 +157,9 @@ export const riskCaches = (): RiskCaches => ({
|
|
|
157
157
|
// the one told: each asks GitHub one thing after another (opening it, and
|
|
158
158
|
// its head) even where all else is kept.
|
|
159
159
|
const READ_AHEAD = 6
|
|
160
|
+
// How many lockfiles' advisories are asked of npm at once, those of the
|
|
161
|
+
// repositories read ahead as they are read, each told in its turn.
|
|
162
|
+
const AUDITS_AT_ONCE = 4
|
|
160
163
|
// How long a part's progress, written as it ends (`progress`), is waited on.
|
|
161
164
|
const PROGRESS_WRITE_MS = 5000
|
|
162
165
|
// How many kept risk lines are read of the source's `risks` at once.
|
|
@@ -180,6 +183,7 @@ interface Scan {
|
|
|
180
183
|
// The repositories told whole, this part or one before (ScanProgress).
|
|
181
184
|
told: Map<number, ToldRepository>
|
|
182
185
|
actions: ReturnType<typeof resolvingActions> | null
|
|
186
|
+
audits: ReturnType<typeof inTurns>
|
|
183
187
|
}
|
|
184
188
|
|
|
185
189
|
// A package as a scan meets it: by its name, version and a mirror's integrity.
|
|
@@ -285,16 +289,16 @@ function assessLater(scan: Scan, packages: LockedPackage[]) {
|
|
|
285
289
|
if (fresh.length > 0) scan.lookups.add(fresh)
|
|
286
290
|
}
|
|
287
291
|
|
|
288
|
-
type AdvisoriesLine = Extract<RepositoryRiskLine, { type: 'advisories' }>
|
|
289
292
|
type LockfileLine = Extract<RepositoryRiskLine, { type: 'lockfile' }>
|
|
290
293
|
|
|
291
|
-
// A lockfile's advisories line (repository-risk-audit.ts auditLockfile)
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
294
|
+
// A lockfile's advisories line, of its audit (repository-risk-audit.ts auditLockfile).
|
|
295
|
+
const advisoriesLine = ({ repo, path }: LockfileLine, audit: Audit) => ({ type: 'advisories', repo, path, ...Array.isArray(audit) ? { advisories: audit } : audit } as const)
|
|
296
|
+
|
|
297
|
+
// A repository read, its lockfiles' audits asked meanwhile, AUDITS_AT_ONCE
|
|
298
|
+
// across the scan (auditLockfiles), to tell in turn once access is checked again.
|
|
299
|
+
type Audited = (RepositoryRead & { audits: Promise<Audit | null>[] }) | RepositoryFailure
|
|
300
|
+
const audited = (scan: Scan, read: RepositoryRead | RepositoryFailure | null): Audited | null => (read === null || 'type' in read ? read : { ...read, audits: auditLockfiles(scan.source, scan.caches.advisories,
|
|
301
|
+
scan.signal, read.lockfiles.map(({ sha, line }) => ({ key: `${line.format}\0${sha}`, packages: 'packages' in line ? line.packages : null })), scan.audits) })
|
|
298
302
|
|
|
299
303
|
type DependabotLine = Extract<RepositoryRiskLine, { type: 'dependabot' }>
|
|
300
304
|
|
|
@@ -319,10 +323,11 @@ async function rechecked(read: DependabotRead): Promise<DependabotRead> {
|
|
|
319
323
|
|
|
320
324
|
// A repository read (repository-risk-read.ts readRepository) told: its
|
|
321
325
|
// line, or why it couldn't be read, then its lockfiles', and their
|
|
322
|
-
// advisories, its packages assessed meanwhile, then
|
|
326
|
+
// advisories, asked as it was read, its packages assessed meanwhile, then
|
|
327
|
+
// its Dependabot alerts.
|
|
323
328
|
// Access to it is checked again
|
|
324
329
|
// just before, however long ago it was read, as GitHub may have withdrawn it.
|
|
325
|
-
async function tellRepository(scan: Scan, repo: RepositoryRiskRepo, read:
|
|
330
|
+
async function tellRepository(scan: Scan, repo: RepositoryRiskRepo, read: Audited | null) {
|
|
326
331
|
if (!read || scan.signal.aborted) return
|
|
327
332
|
if ('type' in read) { scan.lines.add(read); return }
|
|
328
333
|
try { await read.recheck() } catch (error) {
|
|
@@ -337,10 +342,10 @@ async function tellRepository(scan: Scan, repo: RepositoryRiskRepo, read: Reposi
|
|
|
337
342
|
if ('packages' in line) assessLater(scan, line.packages)
|
|
338
343
|
}
|
|
339
344
|
const refs = read.actions ? tellActions(scan, repo, read.actions, lockfiles) : []
|
|
340
|
-
for (const {
|
|
345
|
+
for (const [at, { line }] of lockfiles.entries()) {
|
|
341
346
|
if (scan.signal.aborted) return
|
|
342
|
-
const
|
|
343
|
-
if (
|
|
347
|
+
const audit = await read.audits[at]
|
|
348
|
+
if (audit) scan.lines.add(advisoriesLine(line, audit))
|
|
344
349
|
}
|
|
345
350
|
if (scan.signal.aborted) return
|
|
346
351
|
const dependabot = read.dependabot && await rechecked(read.dependabot)
|
|
@@ -366,9 +371,10 @@ function tellActions(scan: Scan, repo: RepositoryRiskRepo, read: WorkflowsRead,
|
|
|
366
371
|
async function toldBefore(scan: Scan, repo: RepositoryRiskRepo) {
|
|
367
372
|
const told = scan.told.get(repo.repoId)
|
|
368
373
|
if (!told || told.name !== repo.fullName || JSON.stringify(told.paths) !== JSON.stringify(repo.paths ?? null)) return false
|
|
369
|
-
const reads: LockedPackage[][] = []
|
|
374
|
+
const reads: LockedPackage[][] = [], unknown = told.lockfiles.filter(key => !scan.caches.lockfiles.get(key))
|
|
375
|
+
const stored = unknown.length > 0 && scan.source.lockfiles ? await scan.source.lockfiles.readMany(unknown) : null
|
|
370
376
|
for (const key of told.lockfiles) {
|
|
371
|
-
const read = scan.caches.lockfiles.get(key) ??
|
|
377
|
+
const read = scan.caches.lockfiles.get(key) ?? stored?.get(key)?.value
|
|
372
378
|
if (!read || !('packages' in read)) return false
|
|
373
379
|
reads.push(read.packages)
|
|
374
380
|
}
|
|
@@ -388,11 +394,11 @@ async function readRepositories(scan: Scan) {
|
|
|
388
394
|
const kept = await scan.source.progress?.read()
|
|
389
395
|
const progress = kept && Date.now() - kept.at < SCAN_READ_KEPT_MS ? kept.value.repos : []
|
|
390
396
|
for (const told of progress) if (listed.has(told.id)) scan.told.set(told.id, told)
|
|
391
|
-
const reads: Promise<
|
|
397
|
+
const reads: Promise<Audited | null>[] = []
|
|
392
398
|
const read = (at: number) => {
|
|
393
399
|
if (reads[at]) return reads[at]
|
|
394
400
|
const repo = repos[at]!
|
|
395
|
-
const started = reads[at] = toldBefore(scan, repo).then(told => told ? null : readRepository(scan, repo))
|
|
401
|
+
const started = reads[at] = toldBefore(scan, repo).then(async told => (told ? null : audited(scan, await readRepository(scan, repo))))
|
|
396
402
|
// One read ahead and no longer waited on, as the scan stopped, fails alone.
|
|
397
403
|
started.catch(() => {})
|
|
398
404
|
return started
|
|
@@ -438,7 +444,8 @@ export async function* repositoryRiskLines<R extends RepositoryRiskRepo>(source:
|
|
|
438
444
|
const lines = queue<RepositoryRiskLine>(AbortSignal.any([signal, part.signal]), { drain: true })
|
|
439
445
|
const actions = source.actions ? resolvingActions({ source: source.actions, signal: running, tell: line => { lines.add(line) }, memory: caches.actions,
|
|
440
446
|
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
|
|
447
|
+
const scan: Scan = { source, caches, signal: running, lines, lookups: queue(running), seen: new Map(), dev: new Map(), stale: [], told: new Map(), actions,
|
|
448
|
+
audits: inTurns(AUDITS_AT_ONCE) }
|
|
442
449
|
const failed: unknown[] = []
|
|
443
450
|
void (async () => {
|
|
444
451
|
try {
|