@preventive/triage 1.0.0-alpha.42 → 1.0.0-alpha.43
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/managed/report-filter.ts +2 -1
- package/common/security-links.d.ts +5 -0
- package/common/security-links.js +33 -0
- package/out/{chunk-WOTMRDM3.js → chunk-7RU5EDRO.js} +61 -61
- package/out/{chunk-D7AF2JXL.js → chunk-A7M4GV7M.js} +1 -1
- package/out/{chunk-DGVQPIYB.js → chunk-LAAB5SZT.js} +1 -1
- package/out/{chunk-YQRVLRDH.js → chunk-NWZ4PVPN.js} +1 -1
- package/out/{chunk-YP2537WX.js → chunk-OSIMGFUF.js} +1 -1
- package/out/{chunk-Q7ZSIEHP.js → chunk-XOCAPDCM.js} +1 -1
- package/out/client-managed.js +19 -19
- package/out/index.html +2 -2
- package/out/view.js +54 -54
- package/package.json +9 -3
- package/risk/index.d.ts +26 -0
- package/risk/index.js +1 -1
- package/risk/npm/assess.js +60 -14
- package/risk/npm/dependencies.js +11 -3
- package/risk/npm/new-dependency.js +42 -12
- package/risk/npm/registry.js +122 -21
- package/server-managed/http.ts +63 -26
- package/server-managed/locked-packages.ts +18 -6
- package/server-managed/npm-kept.ts +38 -14
- package/server-managed/npm-packages.ts +27 -13
- package/server-managed/npm-risk.ts +69 -11
- package/server-managed/queue.ts +23 -0
- package/server-managed/report-sources.ts +1 -1
- package/server-managed/repository-risk-assess.ts +105 -0
- package/server-managed/repository-risk-audit.ts +66 -0
- package/server-managed/repository-risk.ts +255 -201
- package/server-managed/scan-downloads.ts +87 -0
- package/server-managed/sql.ts +1 -1
- package/server-managed/team-reports.ts +6 -11
- package/server-managed/upstream-cache.ts +16 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// A scan's packages' downloads, as risk asks them, readied before the
|
|
2
|
+
// packages are assessed (repository-risk.ts), so that no assessment waits on
|
|
3
|
+
// npm's downloads API, which answers some 40 lookups a minute: a package is
|
|
4
|
+
// given to the assessors once its downloads are kept (npm-risk.ts
|
|
5
|
+
// npmReadyScanDownloads), those that can be readied at once (kept already,
|
|
6
|
+
// and unscoped ones, asked in bulk) first, each lockfile's as it is read, and
|
|
7
|
+
// the rest (scoped ones, which npm answers only one by one) one at a time,
|
|
8
|
+
// at the pace npm allows, while the assessors go on with the others.
|
|
9
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
10
|
+
import { type Queue, queue } from './queue.ts'
|
|
11
|
+
|
|
12
|
+
export interface DownloadsSource {
|
|
13
|
+
// Readies those of `names` that can be at once, `ready` told each.
|
|
14
|
+
atOnce(names: string[], ready: (name: string) => void, signal: AbortSignal): Promise<void>
|
|
15
|
+
// Readies one on its own.
|
|
16
|
+
one(name: string, signal: AbortSignal): Promise<void>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// How npm asking to be asked later is told (`busy`), and waited on: asked
|
|
20
|
+
// again after a wait, up to `retries` times running.
|
|
21
|
+
export interface DownloadsPacing { busy(error: unknown): boolean; retries: number; waitMs: number }
|
|
22
|
+
|
|
23
|
+
export interface ReadyingDownloads<T> {
|
|
24
|
+
// Packages to give once their downloads are ready.
|
|
25
|
+
add(items: T[]): void
|
|
26
|
+
// Once all added are given, or the scan stops.
|
|
27
|
+
close(): Promise<void>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Readies the downloads of the packages added, each given (`give`) once its
|
|
31
|
+
// name's are, or once readying them fails otherwise than npm asking to wait,
|
|
32
|
+
// or npm keeps asking past `retries`: its assessment then asks its own.
|
|
33
|
+
export function readyingDownloads<T extends { name: string }>(source: DownloadsSource, signal: AbortSignal, give: (item: T) => void,
|
|
34
|
+
{ busy, retries, waitMs }: DownloadsPacing): ReadyingDownloads<T> {
|
|
35
|
+
const waiting = new Map<string, T[]>()
|
|
36
|
+
const atOnce: Queue<string[]> = queue(signal), oneByOne: Queue<string> = queue(signal)
|
|
37
|
+
const release = (name: string) => { for (const item of waiting.get(name) ?? []) give(item); waiting.delete(name) }
|
|
38
|
+
const wait = async (attempt: number) => { try { await delay(waitMs * Math.min(attempt + 1, 5), undefined, { signal }); return true } catch { return false } }
|
|
39
|
+
|
|
40
|
+
// A lockfile's names, those that can be readied at once, again after a wait
|
|
41
|
+
// where npm asks: false once the scan stops.
|
|
42
|
+
const readyNow = async (names: string[]) => {
|
|
43
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
44
|
+
const left = names.filter(name => waiting.has(name))
|
|
45
|
+
try { await source.atOnce(left, release, signal); return true } catch (error) {
|
|
46
|
+
if (signal.aborted) return false
|
|
47
|
+
if (!busy(error)) { for (const name of left) release(name); return true }
|
|
48
|
+
if (!await wait(attempt)) return false
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return true
|
|
52
|
+
}
|
|
53
|
+
// Each lockfile's, the rest left to oneByOne.
|
|
54
|
+
const readyAtOnce = async () => {
|
|
55
|
+
for (let names = await atOnce.take(); names !== undefined; names = await atOnce.take()) {
|
|
56
|
+
if (!await readyNow(names)) return
|
|
57
|
+
for (const name of names) if (waiting.has(name)) oneByOne.add(name)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const readyOneByOne = async () => {
|
|
61
|
+
for (let name = await oneByOne.take(); name !== undefined; name = await oneByOne.take()) {
|
|
62
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
63
|
+
try { await source.one(name, signal); break } catch (error) {
|
|
64
|
+
if (signal.aborted) return
|
|
65
|
+
if (!busy(error) || !await wait(attempt)) break
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
release(name)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const lanes = [readyAtOnce().finally(() => { oneByOne.close() }), readyOneByOne()]
|
|
72
|
+
return {
|
|
73
|
+
add(items) {
|
|
74
|
+
const names: string[] = []
|
|
75
|
+
for (const item of items) {
|
|
76
|
+
const kept = waiting.get(item.name)
|
|
77
|
+
if (kept) kept.push(item)
|
|
78
|
+
else { waiting.set(item.name, [item]); names.push(item.name) }
|
|
79
|
+
}
|
|
80
|
+
if (names.length > 0) atOnce.add(names)
|
|
81
|
+
},
|
|
82
|
+
async close() {
|
|
83
|
+
atOnce.close()
|
|
84
|
+
await Promise.all(lanes)
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|
package/server-managed/sql.ts
CHANGED
|
@@ -22,7 +22,7 @@ export interface ManagedSqlDriver extends ManagedSql {
|
|
|
22
22
|
const SINGLE_STATEMENTS = new Set(['getReport', 'getBundle', 'getStorageRow', 'getStorageEncryption',
|
|
23
23
|
'listReports', 'listBundles', 'listReadableBundleIds', 'listAllRepos', 'listSelectedRepos', 'listRepoScopesForUser', 'listTriage',
|
|
24
24
|
'getFeedState', 'getWorkspaceShareFeedState', 'claimMaintenanceLease', 'finishMaintenanceLease', 'cacheRepoDefaultBranch', 'releaseBundleBuildLease',
|
|
25
|
-
'getUpstreamCacheEntry', 'getUpstreamCacheEntries', 'setUpstreamCacheEntry', 'ensureSessionUploadKey',
|
|
25
|
+
'getUpstreamCacheEntry', 'getUpstreamCacheEntries', 'setUpstreamCacheEntry', 'setUpstreamCacheEntries', 'ensureSessionUploadKey',
|
|
26
26
|
'listGithubCommits', 'listGithubCommitTags', 'setGithubCommits'])
|
|
27
27
|
// Session presence and upload keys change only their own rows.
|
|
28
28
|
const UNLOCKED_WRITES = new Set(['sessionWithUser', 'viewSessionWithUser', 'ensureSessionUploadKey'])
|
|
@@ -5,6 +5,7 @@ import { Buffer } from 'node:buffer'
|
|
|
5
5
|
import { backfillFindingIds, reportEntries, stampSecurityGroups } from '@preventive/report'
|
|
6
6
|
import { managedFindingSourcePaths, readManagedReport } from '../common/managed/report-content.ts'
|
|
7
7
|
import { filterReportData, projectFinding } from '../common/managed/report-filter.ts'
|
|
8
|
+
import { revalidationLinks, securityLinks } from '../common/security-links.js'
|
|
8
9
|
import type { BlobStore } from './blob-store.ts'
|
|
9
10
|
import type { ManagedDb, TeamReportAccessSnapshot } from './db.ts'
|
|
10
11
|
import { triageWireEntry } from './triage-response.ts'
|
|
@@ -104,18 +105,12 @@ async function buildTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: Tea
|
|
|
104
105
|
}))
|
|
105
106
|
for (const result of results) if (result.status === 'rejected') throw result.reason
|
|
106
107
|
const globalLinks = snapshot.linkRevision ? await db.getEnabledLinkGroups(snapshot.linkRevision) : []
|
|
107
|
-
const edges = new Map<string, Set<string>>()
|
|
108
|
-
const connect = (a: string, b: string) => {
|
|
109
|
-
if (!edges.has(a)) edges.set(a, new Set())
|
|
110
|
-
edges.get(a)!.add(b)
|
|
111
|
-
}
|
|
112
|
-
// A star is enough for transitive security propagation, without a quadratic
|
|
113
|
-
// expansion for large links. Unknown IDs can connect known findings.
|
|
114
|
-
for (const ids of globalLinks.concat(reports.flatMap(report => linksOf(report.data) ?? []))) {
|
|
115
|
-
for (const id of ids.slice(1)) { connect(ids[0]!, id); connect(id, ids[0]!) }
|
|
116
|
-
}
|
|
117
108
|
const groups = reports.flatMap(report => groupsOf(report.data).map(group => group.map(f => projectFinding(f, report.data as Finding)!)))
|
|
118
|
-
|
|
109
|
+
// Unknown IDs can connect known findings. App findings link to the inputs
|
|
110
|
+
// they revalidated in other reports, as the App view counts them.
|
|
111
|
+
stampSecurityGroups(groups, { linkedIds: securityLinks([
|
|
112
|
+
...globalLinks, ...reports.flatMap(report => linksOf(report.data) ?? []), ...revalidationLinks(groups.flat()),
|
|
113
|
+
]).linkedIds })
|
|
119
114
|
const securityIds = new Set(groups.flat().filter(f => f['isSecurity'] === true).map(f => String(f['id'])))
|
|
120
115
|
const allIds = new Set<string>(), visible: Visibility = new Map()
|
|
121
116
|
for (let i = 0; i < reports.length; i++) {
|
|
@@ -27,6 +27,9 @@ export interface UpstreamCacheStore {
|
|
|
27
27
|
// Key → value for those of `keys` that are kept.
|
|
28
28
|
getUpstreamCacheEntries(keys: string[]): Promise<Map<string, string>>
|
|
29
29
|
setUpstreamCacheEntry(key: string, value: string, cachedAt: number): Promise<void>
|
|
30
|
+
// Each of `entries` (key, value), as setUpstreamCacheEntry keeps one, in one
|
|
31
|
+
// statement: the last of a key given twice.
|
|
32
|
+
setUpstreamCacheEntries(entries: [key: string, value: string][], cachedAt: number): Promise<void>
|
|
30
33
|
// Removes those under `prefix` (no LIKE wildcard in it) kept before `before`;
|
|
31
34
|
// how many.
|
|
32
35
|
deleteUpstreamCacheEntries(prefix: string, before: number): Promise<number>
|
|
@@ -40,6 +43,8 @@ export function upstreamCacheMethods(db: ManagedSql): UpstreamCacheStore {
|
|
|
40
43
|
ON CONFLICT (cache_key) DO UPDATE SET value = excluded.value, cached_at = excluded.cached_at
|
|
41
44
|
WHERE managed_upstream_cache.cached_at <= excluded.cached_at`)
|
|
42
45
|
const remove = db.prepare('DELETE FROM managed_upstream_cache WHERE cache_key LIKE ? AND cached_at < ?')
|
|
46
|
+
// setUpstreamCacheEntries's, by how many it keeps at once.
|
|
47
|
+
const setMany = new Map<number, ReturnType<ManagedSql['prepare']>>()
|
|
43
48
|
return {
|
|
44
49
|
async getUpstreamCacheEntry(key) {
|
|
45
50
|
const row = await get.get(key) as { value: string } | undefined
|
|
@@ -53,6 +58,17 @@ export function upstreamCacheMethods(db: ManagedSql): UpstreamCacheStore {
|
|
|
53
58
|
async setUpstreamCacheEntry(key, value, cachedAt) {
|
|
54
59
|
await set.run(key, value, cachedAt)
|
|
55
60
|
},
|
|
61
|
+
async setUpstreamCacheEntries(entries, cachedAt) {
|
|
62
|
+
const rows = [...new Map(entries)]
|
|
63
|
+
if (rows.length === 0) return
|
|
64
|
+
let many = setMany.get(rows.length)
|
|
65
|
+
if (!many) {
|
|
66
|
+
setMany.set(rows.length, many = db.prepare(`INSERT INTO managed_upstream_cache (cache_key, value, cached_at) VALUES ${rows.map(() => '(?, ?, ?)').join(', ')}
|
|
67
|
+
ON CONFLICT (cache_key) DO UPDATE SET value = excluded.value, cached_at = excluded.cached_at
|
|
68
|
+
WHERE managed_upstream_cache.cached_at <= excluded.cached_at`))
|
|
69
|
+
}
|
|
70
|
+
await many.run(...rows.flatMap(([key, value]) => [key, value, cachedAt]))
|
|
71
|
+
},
|
|
56
72
|
async deleteUpstreamCacheEntries(prefix, before) {
|
|
57
73
|
return Number((await remove.run(`${prefix}%`, before)).changes)
|
|
58
74
|
},
|