@preventive/triage 1.0.0-alpha.37 → 1.0.0-alpha.38

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.
Files changed (175) hide show
  1. package/README.md +16 -0
  2. package/common/bundle-compare-diff.d.ts +3 -0
  3. package/common/bundle-compare-diff.js +522 -0
  4. package/common/bundle-compare-inputs.d.ts +2 -0
  5. package/common/bundle-compare-inputs.js +65 -0
  6. package/common/bundle-metadata.d.ts +6 -2
  7. package/common/bundle-metadata.js +63 -20
  8. package/common/bundle-pkg-of.d.ts +1 -0
  9. package/common/bundle-pkg-of.js +90 -0
  10. package/common/bundle-source-links.js +7 -0
  11. package/common/bundle-sourcemap.d.ts +10 -0
  12. package/common/bundle-sourcemap.js +100 -0
  13. package/common/bundle-sources.d.ts +3 -1
  14. package/common/bundle-sources.js +47 -19
  15. package/common/code-language.js +16 -2
  16. package/common/default-scan-models.ts +2 -1
  17. package/common/dependency-reviews.d.ts +23 -0
  18. package/common/dependency-reviews.js +81 -0
  19. package/common/finding-duplicates.js +58 -0
  20. package/common/finding-filters.js +274 -0
  21. package/common/github-pr.ts +1 -1
  22. package/common/gzip.js +59 -0
  23. package/common/line-diff.d.ts +8 -0
  24. package/common/line-diff.js +56 -0
  25. package/common/linked-workspace-groups.js +63 -0
  26. package/common/managed/npm-packages.d.ts +5 -0
  27. package/common/managed/npm-packages.js +34 -0
  28. package/common/managed/permissions.ts +1 -5
  29. package/common/managed/routes.js +123 -12
  30. package/common/managed/team-app.d.ts +2 -0
  31. package/common/managed/team-app.js +31 -0
  32. package/common/managed/triage.ts +1 -1
  33. package/common/managed/upload-seal.ts +126 -0
  34. package/common/pretty-print.d.ts +5 -0
  35. package/common/pretty-print.js +24 -0
  36. package/common/revalidation-conflicts.js +48 -0
  37. package/common/revalidation-input-groups.js +56 -0
  38. package/common/workspace-app.js +23 -0
  39. package/common/workspace-groups.js +176 -0
  40. package/out/brotli-fallback.js +3 -3
  41. package/out/chunk-2KBF5TZ7.js +2 -0
  42. package/out/chunk-2XUEZHIP.js +28 -0
  43. package/out/chunk-35VE6CDH.js +2 -0
  44. package/out/chunk-3BZ7JERB.js +1 -0
  45. package/out/chunk-4XCDFGDP.js +10 -0
  46. package/out/chunk-5FKRIJGF.js +1 -0
  47. package/out/chunk-6Z5XLSPJ.js +1 -0
  48. package/out/chunk-ABW5FVSN.js +1 -0
  49. package/out/chunk-BJKRVROW.js +1 -0
  50. package/out/chunk-BYUEDJOM.js +7 -0
  51. package/out/chunk-C4Z6ZBXV.js +10 -0
  52. package/out/chunk-CMQA4DM6.js +1 -0
  53. package/out/chunk-CVVJAXVY.js +2 -0
  54. package/out/chunk-DCQX2J3C.js +223 -0
  55. package/out/chunk-DIJJNTRH.js +10 -0
  56. package/out/chunk-F4OMDNEY.js +1 -0
  57. package/out/chunk-FU4MTW7F.js +1 -0
  58. package/out/chunk-GJ4IUCNS.js +1 -0
  59. package/out/chunk-GMFXFEUA.js +2 -0
  60. package/out/chunk-GNXOKIGP.js +80 -0
  61. package/out/chunk-GZ4MS3TE.js +13 -0
  62. package/out/chunk-HIV7SVI7.js +1 -0
  63. package/out/chunk-IU3CCINR.js +3 -0
  64. package/out/chunk-JAKWIZWL.js +2 -0
  65. package/out/chunk-K3X5EJBQ.js +2 -0
  66. package/out/chunk-KEX2ATEW.js +2 -0
  67. package/out/chunk-KYCNJVB7.js +1 -0
  68. package/out/chunk-NRPC74AM.js +1 -0
  69. package/out/chunk-OJ5DGP5E.js +33 -0
  70. package/out/chunk-OOJM4CTU.js +1 -0
  71. package/out/chunk-PNHBZGDD.js +1 -0
  72. package/out/chunk-R5O6FAW4.js +2 -0
  73. package/out/chunk-RFZQQXSG.js +17 -0
  74. package/out/chunk-SD2WJAXN.js +48 -0
  75. package/out/chunk-TUII34K2.js +1 -0
  76. package/out/chunk-TZ5KB2Y5.js +10 -0
  77. package/out/chunk-UNTRGE26.js +2 -0
  78. package/out/chunk-UYAXVRVL.js +1 -0
  79. package/out/chunk-V3ME6FI5.js +2 -0
  80. package/out/chunk-V5O7M2YT.js +1 -0
  81. package/out/chunk-VIHGO77Y.js +1 -0
  82. package/out/chunk-W4J3MGLB.js +1 -0
  83. package/out/chunk-WGG7KQUH.js +1 -0
  84. package/out/chunk-XYFXXB2G.js +10 -0
  85. package/out/chunk-YVZH5SHP.js +1 -0
  86. package/out/chunk-ZPD5R5RP.js +3 -0
  87. package/out/client-managed-import.js +3 -87
  88. package/out/client-managed.js +26 -338
  89. package/out/client-sync.js +8 -14
  90. package/out/graph.js +17 -30
  91. package/out/index.html +34 -0
  92. package/out/prism.js +6 -2
  93. package/out/terminal.js +241 -257
  94. package/out/view.css +1 -1
  95. package/out/view.js +76 -520
  96. package/package.json +88 -7
  97. package/risk/files/characters.js +196 -0
  98. package/risk/files/hosts.js +205 -0
  99. package/risk/files/readability.js +164 -0
  100. package/risk/files/runs-code.js +329 -0
  101. package/risk/files/script.js +112 -0
  102. package/risk/files/stamps.js +46 -0
  103. package/risk/index.d.ts +142 -0
  104. package/risk/index.js +7 -0
  105. package/risk/npm/assess.js +219 -0
  106. package/risk/npm/combined.js +65 -0
  107. package/risk/npm/compare.js +222 -0
  108. package/risk/npm/dependencies.js +153 -0
  109. package/risk/npm/diff.js +43 -0
  110. package/risk/npm/downloads.js +84 -0
  111. package/risk/npm/files.js +34 -0
  112. package/risk/npm/held.js +60 -0
  113. package/risk/npm/install-scripts.js +33 -0
  114. package/risk/npm/malware.js +36 -0
  115. package/risk/npm/manifest.js +65 -0
  116. package/risk/npm/new-dependency.js +30 -0
  117. package/risk/npm/old-version.js +108 -0
  118. package/risk/npm/registry.js +282 -0
  119. package/risk/npm/release.js +85 -0
  120. package/risk/npm/specs.js +124 -0
  121. package/risk/npm/tarball.js +113 -0
  122. package/server-e2e/http.ts +7 -7
  123. package/server-e2e/npm-proxy.ts +1 -1
  124. package/server-e2e/objstore/fetch-mint-guard.ts +2 -2
  125. package/server-e2e/objstore/handlers.ts +1 -1
  126. package/server-e2e/objstore/tokens.ts +1 -1
  127. package/server-e2e/sign.ts +1 -1
  128. package/server-managed/bundle-advisories.ts +68 -11
  129. package/server-managed/bundle-cache.ts +37 -4
  130. package/server-managed/bundle-commits.ts +140 -0
  131. package/server-managed/bundle-summary-cache.ts +24 -10
  132. package/server-managed/config.ts +4 -0
  133. package/server-managed/db-methods.ts +98 -11
  134. package/server-managed/db-neon.ts +39 -4
  135. package/server-managed/db-schema.ts +14 -2
  136. package/server-managed/db-table-names.ts +1 -1
  137. package/server-managed/db.ts +7 -3
  138. package/server-managed/dependency-reviews.ts +248 -0
  139. package/server-managed/github-issue-oauth.ts +5 -6
  140. package/server-managed/github-metadata.ts +115 -0
  141. package/server-managed/github-oauth.ts +38 -2
  142. package/server-managed/http-response.ts +26 -0
  143. package/server-managed/http.ts +943 -52
  144. package/server-managed/index.ts +11 -5
  145. package/server-managed/kept.ts +28 -0
  146. package/server-managed/locked-packages.ts +85 -0
  147. package/server-managed/lru.ts +30 -0
  148. package/server-managed/npm-insights.ts +440 -0
  149. package/server-managed/npm-kept.ts +65 -0
  150. package/server-managed/npm-loads.ts +167 -0
  151. package/server-managed/npm-manifest.ts +115 -0
  152. package/server-managed/npm-packages.ts +378 -0
  153. package/server-managed/npm-provenance.ts +123 -0
  154. package/server-managed/npm-risk.ts +177 -0
  155. package/server-managed/pretty-print.ts +352 -0
  156. package/server-managed/public-workspace.ts +78 -7
  157. package/server-managed/rename-diff-worker.js +26 -0
  158. package/server-managed/rename-diff.ts +270 -0
  159. package/server-managed/report-sources.ts +13 -2
  160. package/server-managed/repository-browser.ts +19 -5
  161. package/server-managed/repository-risk.ts +413 -0
  162. package/server-managed/session.ts +16 -2
  163. package/server-managed/sql.ts +5 -2
  164. package/server-managed/storage-encryption.ts +5 -3
  165. package/server-managed/storage-payload.ts +4 -0
  166. package/server-managed/storage-stores.ts +2 -0
  167. package/server-managed/storage.ts +6 -3
  168. package/server-managed/team-app.ts +94 -0
  169. package/server-managed/team-feed.ts +1 -1
  170. package/server-managed/team-npm-scopes.ts +77 -0
  171. package/server-managed/type-only-worker.js +19 -0
  172. package/server-managed/type-only.ts +103 -0
  173. package/server-managed/uploads.ts +25 -4
  174. package/server-managed/upstream-cache.ts +147 -5
  175. package/server-managed/view-as.ts +58 -0
@@ -0,0 +1,124 @@
1
+ // Where npm installs a dependency from, read off its spec as npm-package-arg
2
+ // reads one: the registry (a version, a range, a tag, an `npm:` alias), a
3
+ // path, a repository on a host npm knows, a git URL or a tarball's.
4
+
5
+ // The hosts npm takes a repository on (hosted-git-info, which npm-package-arg
6
+ // reads a spec with): by name (`github:owner/repo`), or by its domain over
7
+ // the protocols each takes, where its path names a repository, `[user,
8
+ // project, committish]`, and not a tarball or a file.
9
+ const trim = project => project?.replace(/\.git$/u, '')
10
+ const GIT_HOSTS = new Map([
11
+ ['github', { label: 'GitHub', domain: 'github.com', protocols: ['git:', 'http:', 'git+ssh:', 'git+https:', 'ssh:', 'https:'], repo({ pathname, hash }) {
12
+ const [, user, project, type, committish] = pathname.split('/', 5)
13
+ return (!type || type === 'tree') && user && trim(project) ? [user, trim(project), type ? committish : hash.slice(1)] : null
14
+ } }],
15
+ ['bitbucket', { label: 'Bitbucket', domain: 'bitbucket.org', protocols: ['git+ssh:', 'git+https:', 'ssh:', 'https:'], repo({ pathname, hash }) {
16
+ const [, user, project, aux] = pathname.split('/', 4)
17
+ return aux !== 'get' && user && trim(project) ? [user, trim(project), hash.slice(1)] : null
18
+ } }],
19
+ ['gitlab', { label: 'GitLab', domain: 'gitlab.com', protocols: ['git+ssh:', 'git+https:', 'ssh:', 'https:'], repo({ pathname, hash }) {
20
+ const path = pathname.slice(1)
21
+ const at = path.lastIndexOf('/')
22
+ if (path.includes('/-/') || path.includes('/archive.tar.gz')) return null
23
+ return path.slice(0, Math.max(at, 0)) && trim(path.slice(at + 1)) ? [path.slice(0, at), trim(path.slice(at + 1)), hash.slice(1)] : null
24
+ } }],
25
+ ['gist', { label: 'Gist', domain: 'gist.github.com', protocols: ['git:', 'git+ssh:', 'git+https:', 'ssh:', 'https:'], repo({ pathname, hash }) {
26
+ const [, user, project, aux] = pathname.split('/', 4)
27
+ return aux !== 'raw' && (user || project) ? [project ? user : null, trim(project || user), hash.slice(1)] : null
28
+ } }],
29
+ ['sourcehut', { label: 'SourceHut', domain: 'git.sr.ht', protocols: ['git+ssh:', 'https:'], repo({ pathname, hash }) {
30
+ const [, user, project, aux] = pathname.split('/', 4)
31
+ return aux !== 'archive' && user && trim(project) ? [user, trim(project), hash.slice(1)] : null
32
+ } }],
33
+ ])
34
+ const BY_DOMAIN = new Map([...GIT_HOSTS.values()].map(host => [host.domain, host]))
35
+ // The protocols hosted-git-info reads a URL by as it is, the hosts' names too.
36
+ const PROTOCOLS = new Set(['git+ssh:', 'ssh:', 'git+https:', 'git:', 'http:', 'https:', 'git+http:', ...[...GIT_HOSTS.keys()].map(name => `${name}:`)])
37
+
38
+ // GitHub's `owner/repo` shorthand, before a commit, branch or tag
39
+ // (`#v1.0.0`) or not, as hosted-git-info tells it: a slash, not first, nor
40
+ // last before any `#`; no `.` first; and no space, `@`, `:` or second slash
41
+ // before any `#`.
42
+ function githubShorthand(spec) {
43
+ const hash = spec.indexOf('#'), slash = spec.indexOf('/')
44
+ const beforeHash = at => at > -1 && !(hash > -1 && at > hash)
45
+ return slash > 0 && !spec.startsWith('.') && (hash > -1 ? spec[hash - 1] !== '/' : !spec.endsWith('/'))
46
+ && ![/\s/u.exec(spec)?.index ?? -1, spec.indexOf('@'), spec.indexOf(':'), spec.indexOf('/', slash + 1)].some(beforeHash)
47
+ }
48
+
49
+ // A URL as hosted-git-info parses one: a protocol it doesn't know put
50
+ // before a `git@host:path` (as `git+ssh://`) or given its `//`, and else
51
+ // `host:path` read as `host/path`.
52
+ const safeUrl = text => { try { return new URL(text) } catch { return null } }
53
+ function hostedUrl(spec) {
54
+ const at = spec.indexOf('@'), colon = spec.indexOf(':')
55
+ const corrected = PROTOCOLS.has(spec.slice(0, colon + 1)) || at > -1 && at < colon ? spec : at > -1 ? `git+ssh://${spec}`
56
+ : spec.indexOf('//') === colon + 1 ? spec : `${spec.slice(0, colon + 1)}//${spec.slice(colon + 1)}`
57
+ if (safeUrl(corrected)) return safeUrl(corrected)
58
+ const before = char => { const hash = corrected.indexOf('#'); return corrected.lastIndexOf(char, hash > -1 ? hash : Infinity) }
59
+ let scp = before(':') > before('@') ? `${corrected.slice(0, before(':'))}/${corrected.slice(before(':') + 1)}` : corrected
60
+ if (scp.lastIndexOf(':', scp.indexOf('#') > -1 ? scp.indexOf('#') : Infinity) === -1 && !scp.includes('//')) scp = `git+ssh://${scp}`
61
+ return safeUrl(scp)
62
+ }
63
+
64
+ // The host npm takes a spec's repository on (hosted-git-info's fromUrl), or
65
+ // null: one by its name, whatever its path, or by its domain over a protocol
66
+ // it takes, where its path names a repository; none whose parts don't
67
+ // decode.
68
+ function gitHost(spec) {
69
+ if (!spec) return null
70
+ const url = hostedUrl(githubShorthand(spec) ? `github:${spec}` : spec)
71
+ if (!url) return null
72
+ const named = GIT_HOSTS.get(url.protocol.slice(0, -1))
73
+ const host = named ?? BY_DOMAIN.get(url.hostname.replace(/^www\./u, ''))
74
+ const parts = named ? [url.pathname.replace(/^\/?[^@]*@|^\//u, ''), url.hash.slice(1)] : host?.protocols.includes(url.protocol) && host.repo(url)
75
+ try { return parts ? (parts.forEach(part => part && decodeURIComponent(part)), host) : null } catch { return null }
76
+ }
77
+
78
+ // Where npm installs a dependency's spec from, read once in
79
+ // npm-package-arg's resolve order: `path` for a path (`./x`, `~/x`, `/x`,
80
+ // `c:/x`, `file:`); `registry` for an `npm:` alias of a registry package,
81
+ // or a spec with no `/` or `:` that names no tarball, which no later step
82
+ // takes; a repository on a host npm knows (gitHost), named for it
83
+ // (`GitHub`); for a URL, `git` for a repository's (`git:`, `git+https:`,
84
+ // `git+ssh:`, `git+http:`, `git+rsync:`, `git+ftp:`, `git+file:`, and
85
+ // `git+ssh://` to `user@host:path`), `URL` for a tarball's (`https:`,
86
+ // `http:`), and null for one npm doesn't install (`workspace:`, `ssh:` on
87
+ // another host); then `path` again for one with a `/` or a tarball's name
88
+ // (`a/b/c`, `x.tgz`, `git@example.com:x/y`), and else `registry`: a
89
+ // version, a range or a tag.
90
+ function specFrom(spec) {
91
+ const value = String(spec ?? '').trim()
92
+ if (/^(?:[.]|~\/|\/|[a-z]:|file:)/iu.test(value)) return 'path'
93
+ if (/^npm:/iu.test(value) || !/[/:]|[.](?:tgz|tar\.gz|tar)$/iu.test(value)) return 'registry'
94
+ const host = gitHost(value)
95
+ if (host) return host.label
96
+ if (/^(?:git\+)?[a-z]+:/iu.test(value)) {
97
+ const scp = /^git\+ssh:\/\/([^:#]+:[^#]+(?:\.git)?)(?:#.*)?$/iu.exec(value)
98
+ if (value.startsWith('git+ssh:') && scp && !/:\d+(?:\/|$)/u.test(scp[1])) return 'git'
99
+ const protocol = safeUrl(value)?.protocol
100
+ return /^(?:git|git\+(?:https?|rsync|ftp|file|ssh)):$/u.test(protocol) ? 'git' : /^https?:$/u.test(protocol) ? 'URL' : null
101
+ }
102
+ return /\/|[.](?:tgz|tar\.gz|tar)$/iu.test(value) ? 'path' : 'registry'
103
+ }
104
+
105
+ // Where a dependency's spec has npm fetch it from, where that isn't its
106
+ // registry (specFrom): a host's name (`GitHub`), `git` or `URL`. Null for
107
+ // the registry's ranges and tags, a path, and what npm doesn't install.
108
+ export function npmDependencySource(spec) {
109
+ const from = specFrom(spec)
110
+ return from === 'registry' || from === 'path' ? null : from
111
+ }
112
+
113
+ // The package and spec an `npm:` alias names (`npm:pkg@^1.2.3`), as
114
+ // specFrom reads one; null for a spec that isn't one.
115
+ export function npmAlias(spec) {
116
+ const [, name, range = ''] = /^npm:((?:@[^/@]+\/)?[^/@]+)(?:@(.*))?$/iu.exec(String(spec ?? '').trim()) ?? []
117
+ return name ? { name, spec: range } : null
118
+ }
119
+
120
+ // Whether npm installs a dependency's spec from its registry (specFrom):
121
+ // not from a path, a repository or a URL, nor a scheme npm doesn't install
122
+ // (`workspace:`), the registry's package of that name being what npm
123
+ // installs for none of those.
124
+ export const npmRegistrySpec = spec => specFrom(spec) === 'registry'
@@ -0,0 +1,113 @@
1
+ // A package's files from its tarball, as npm installs them: gunzipped, read as
2
+ // a tar archive (ustar, pax and GNU long names), each file by the path npm
3
+ // installs it at, its first component dropped, none escaping the package; a
4
+ // text where it is UTF-8 with no NUL, else null. Held to the npm viewer's
5
+ // bounds (server-managed/npm-packages.ts readNpmTar).
6
+ const MAX_PACKAGE_FILES = 20_000
7
+ export const MAX_PACKAGE_BYTES = 64 * 1024 * 1024
8
+ // What its tar stream may unpack to, its headers and padding beside its
9
+ // files, as npm-loads.ts bounds it (MAX_TAR_BYTES).
10
+ export const MAX_TAR_BYTES = 96 * 1024 * 1024
11
+
12
+ const latin1 = new TextDecoder('latin1')
13
+ const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
14
+ const utf8Lenient = bytes => new TextDecoder().decode(bytes)
15
+ // A header's text field, to its first NUL.
16
+ const textOf = (bytes, start, length) => {
17
+ const field = bytes.subarray(start, start + length)
18
+ const end = field.indexOf(0)
19
+ return utf8Lenient(end === -1 ? field : field.subarray(0, end))
20
+ }
21
+
22
+ // A header's size field: octal, or GNU base-256 past its 11 digits.
23
+ function sizeOf(bytes, offset) {
24
+ if (bytes[offset] & 0x80) {
25
+ let size = bytes[offset] & 0x7f
26
+ for (let i = 1; i < 12; i++) size = size * 256 + bytes[offset + i]
27
+ return size
28
+ }
29
+ const text = textOf(bytes, offset, 12).trim()
30
+ return /^[0-7]+$/u.test(text) ? Number.parseInt(text, 8) : Number.NaN
31
+ }
32
+
33
+ // A pax header's path: records `<length> <key>=<value>\n`, their lengths in
34
+ // bytes.
35
+ function paxPath(data) {
36
+ let at = 0, path = null
37
+ while (at < data.length) {
38
+ const space = data.indexOf(0x20, at)
39
+ const length = Number.parseInt(latin1.decode(data.subarray(at, space)), 10)
40
+ if (space === -1 || !Number.isSafeInteger(length) || length <= 0) break
41
+ const record = utf8Lenient(data.subarray(space + 1, at + length - 1))
42
+ if (record.startsWith('path=')) path = record.slice(5)
43
+ at += length
44
+ }
45
+ return path
46
+ }
47
+
48
+ const packagePath = raw => {
49
+ const parts = raw.split('/').slice(1).filter(part => part !== '' && part !== '.')
50
+ return parts.length === 0 || parts.includes('..') ? null : parts.join('/')
51
+ }
52
+
53
+ // The files of a tar archive, by path: their bytes.
54
+ function npmTarFiles(tar) {
55
+ const files = new Map()
56
+ let longName = null, offset = 0, total = 0
57
+ while (offset + 512 <= tar.length) {
58
+ const header = tar.subarray(offset, offset + 512)
59
+ if (header.every(byte => byte === 0)) break
60
+ const size = sizeOf(header, 124), start = offset + 512, type = String.fromCodePoint(header[156])
61
+ if (!Number.isSafeInteger(size) || size < 0 || start + size > tar.length) throw new Error('bad-tarball')
62
+ const data = tar.subarray(start, start + size)
63
+ offset = start + Math.ceil(size / 512) * 512
64
+ if (type === 'x' || type === 'L') { longName = type === 'x' ? paxPath(data) : textOf(data, 0, data.length); continue }
65
+ if (type === 'g' || type === 'K') continue
66
+ const prefix = textOf(header, 257, 6) === 'ustar' ? textOf(header, 345, 155) : ''
67
+ const name = longName ?? (prefix ? `${prefix}/${textOf(header, 0, 100)}` : textOf(header, 0, 100))
68
+ longName = null
69
+ const path = ['0', '\0', '7'].includes(type) ? packagePath(name) : null
70
+ if (path === null) continue
71
+ total += size - (files.get(path)?.length ?? 0)
72
+ files.set(path, data)
73
+ if (files.size > MAX_PACKAGE_FILES || total > MAX_PACKAGE_BYTES) throw new Error('package-too-large')
74
+ }
75
+ return files
76
+ }
77
+
78
+ // A file's text: UTF-8 with no NUL, else null.
79
+ export function npmFileText(bytes) {
80
+ if (bytes.includes(0)) return null
81
+ try { return utf8.decode(bytes) } catch { return null }
82
+ }
83
+
84
+ // A gzipped tarball's tar stream, given up on as `package-too-large` once it
85
+ // unpacks past MAX_TAR_BYTES, before any more of it is held.
86
+ async function gunzip(gzipped) {
87
+ const reader = new Blob([gzipped]).stream().pipeThrough(new DecompressionStream('gzip')).getReader()
88
+ const chunks = []
89
+ let size = 0
90
+ for (let read = await reader.read(); !read.done; read = await reader.read()) {
91
+ size += read.value.byteLength
92
+ if (size > MAX_TAR_BYTES) { await reader.cancel(); throw new Error('package-too-large') }
93
+ chunks.push(read.value)
94
+ }
95
+ return new Uint8Array(await new Blob(chunks).arrayBuffer())
96
+ }
97
+
98
+ // A file's bytes' digest, as the managed server gives one of a file that
99
+ // isn't text (server-managed/npm-packages.ts).
100
+ const sha256 = async bytes => `sha256-${btoa(String.fromCodePoint(...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))))}`
101
+
102
+ // A gzipped tarball's files as a version holds them for risk, as the npm
103
+ // viewer holds a version (ui/view/npm-package.js npmPackageDetails), a source
104
+ // map listing them (`kind: 'sourcemap'`): `json.sources` their paths, sorted,
105
+ // `json.sourcesContent` their texts, `fileSizes` by path, and `npmBinaries`,
106
+ // those that aren't text, `{ size, digest }` by path, which compare.js and
107
+ // diff.js tell them by.
108
+ export async function npmTarballVersion(gzipped) {
109
+ const files = [...npmTarFiles(await gunzip(gzipped))].toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
110
+ const texts = files.map(([, bytes]) => npmFileText(bytes))
111
+ const binaries = await Promise.all(files.filter((_, at) => texts[at] === null).map(async ([path, bytes]) => [path, { size: bytes.length, digest: await sha256(bytes) }]))
112
+ return { kind: 'sourcemap', json: { version: 3, sources: files.map(([path]) => path), sourcesContent: texts }, fileSizes: new Map(files.map(([path, bytes]) => [path, bytes.length])), npmBinaries: new Map(binaries) }
113
+ }
@@ -26,7 +26,7 @@ export const WS_UPGRADE_PATH = '/api/sync'
26
26
  // here commits + broadcasts WITHOUT taking over the client's SSE event-stream
27
27
  // (each in-band POST forces a stream takeover; see sse-server.ts). Sibling of
28
28
  // the WS upgrade path so one nginx `/api/*` block routes both.
29
- export const SAVE_REST_PATH = '/api/sync/save'
29
+ const SAVE_REST_PATH = '/api/sync/save'
30
30
  function isUpgradePath(url: string | undefined): boolean {
31
31
  if (typeof url !== 'string') return false
32
32
  // Strip `?…` so clients can carry build / debug tags. Exact match
@@ -174,12 +174,12 @@ export function createHttpServer(deps: HttpServerDeps): Server {
174
174
  // REST handlers go through a separate path and must mirror it.
175
175
  // Without this, a REST PUT arriving on an existing keep-alive
176
176
  // socket AFTER SIGTERM but BEFORE `httpServer.close()` finishes
177
- // draining could land in `withCommitLock`, acquire a lease, and
178
- // finish its `finally { release() }` AFTER the shutdown's
179
- // `heldLeaseCount` snapshot — leaving an orphan lock row that pins
180
- // the key until TTL expiry. The 503 + `shutting-down` reason tells
181
- // the client to retry against a different replica. Transport
182
- // audit + multi-replica shutdown ordering review.
177
+ // draining could start a fresh upload + commit for the drain to
178
+ // outwait — or, landing after the drain's in-flight snapshot
179
+ // (lifecycle.ts `drain`), run on against the closed DB, the race
180
+ // ws-server.ts gates on the WS plane. The 503 + `shutting-down`
181
+ // reason tells the client to retry against a different replica.
182
+ // Transport audit + multi-replica shutdown ordering review.
183
183
  if (isShuttingDown()) {
184
184
  res.writeHead(503, { 'content-type': 'application/json', 'connection': 'close' })
185
185
  res.end(JSON.stringify({ error: 'shutting-down' }))
@@ -141,7 +141,7 @@ async function readRequestBody(req: IncomingMessage): Promise<Buffer | null> {
141
141
  return Buffer.concat(chunks)
142
142
  }
143
143
 
144
- export async function handleNpmAdvisories(
144
+ async function handleNpmAdvisories(
145
145
  deps: NpmProxyDeps,
146
146
  req: IncomingMessage,
147
147
  res: ServerResponse,
@@ -35,8 +35,8 @@ export type FetchMintGuard = {
35
35
  size: () => number
36
36
  }
37
37
 
38
- export const DEFAULT_FETCH_MINT_WINDOW_MS = 60_000
39
- export const DEFAULT_FETCH_MINT_MAX_ENTRIES = 50_000
38
+ const DEFAULT_FETCH_MINT_WINDOW_MS = 60_000
39
+ const DEFAULT_FETCH_MINT_MAX_ENTRIES = 50_000
40
40
 
41
41
  export function createFetchMintGuard(
42
42
  { windowMs = DEFAULT_FETCH_MINT_WINDOW_MS, maxEntries = DEFAULT_FETCH_MINT_MAX_ENTRIES }:
@@ -229,7 +229,7 @@ async function handleFetch(deps: ObjstoreDeps, socket: WebSocket, msg: ObjstoreF
229
229
  // the reaper's TTL pass within `STAGING_TTL_MS_DEFAULT` — no per-
230
230
  // socket bookkeeping is needed on disconnect today. If that ever
231
231
  // changes, wire a `cleanupSocket(socket)` into both this bundle and
232
- // server-e2e/index.ts's close handler.
232
+ // server-e2e/ws-server.ts's per-socket close handler.
233
233
 
234
234
  export type ObjstoreHandlers = {
235
235
  handlePutBegin: (s: WebSocket, m: ObjstorePutBeginMsg) => Promise<void>
@@ -24,7 +24,7 @@
24
24
  import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
25
25
  import { Buffer } from 'node:buffer'
26
26
 
27
- export const DEFAULT_TOKEN_TTL_MS = 5 * 60 * 1000
27
+ const DEFAULT_TOKEN_TTL_MS = 5 * 60 * 1000
28
28
 
29
29
  export type PutTokenPayload = {
30
30
  op: 'put'
@@ -157,7 +157,7 @@ export async function verifyEd25519(
157
157
  // `{ ok: false, canonical: null }` on bad shape / bad sig;
158
158
  // `{ ok: true, canonical: <bytes> }` on success.
159
159
  //
160
- // NOT used by `server-e2e/index.ts handleSave`. Production composes
160
+ // NOT used by `server-e2e/sync-handlers.ts handleSave`. Production composes
161
161
  // the smaller helpers (`canonicalSave`, `computeRevisionIdFromCanonical`,
162
162
  // `verifyEd25519`) separately so the dup-precheck via
163
163
  // `revisionExists` can fire BEFORE Ed25519-verify and skip the
@@ -1,23 +1,70 @@
1
1
  import { type Advisory, type CacheStore, type Package, advisories } from '@preventive/upstream/advisories.js'
2
2
  import { type Client, HttpError, createClient } from '@preventive/upstream/github.js'
3
+ import { isNpmPackageName, npmPackageScope } from '../common/managed/npm-packages.js'
4
+ import { NPM_REGISTRY, askUpstream } from './npm-packages.ts'
3
5
 
4
6
  export const ADVISORIES_TIMEOUT_MS = 30_000
7
+ const PUBLIC_AT_ONCE = 16
8
+ const order = (a: string, b: string) => Number(a > b) - Number(a < b)
9
+
10
+ // Whether anyone may read a package, so that its name may go to a service
11
+ // other than npm (OSV): an unscoped one always, npm's private packages
12
+ // always being scoped; a scoped one where npm answers it anonymously, asked
13
+ // by a HEAD, which reads nothing of it. One npm can't be asked of is taken
14
+ // as private, and not kept; the rest are, for PUBLIC_MS, PUBLIC_NAMES at
15
+ // most, as a package may be made private, or public. A name npm can't have
16
+ // is asked of no one, upstream refusing it.
17
+ const PUBLIC_MS = 60 * 60_000
18
+ const PUBLIC_NAMES = 10_000
19
+ const publicNames = new Map<string, { at: number; open: boolean }>()
20
+ async function isPublicNpmPackage(name: string, signal: AbortSignal): Promise<boolean> {
21
+ if (!isNpmPackageName(name)) return false
22
+ if (npmPackageScope(name) === null) return true
23
+ const kept = publicNames.get(name)
24
+ if (kept && Date.now() - kept.at < PUBLIC_MS) return kept.open
25
+ let open
26
+ try { open = await askUpstream(`${NPM_REGISTRY}/${name}`, { method: 'HEAD', limit: Number.MAX_SAFE_INTEGER, signal, absent: [401, 403, 404] }) !== null } catch { return false }
27
+ publicNames.delete(name)
28
+ publicNames.set(name, { at: Date.now(), open })
29
+ while (publicNames.size > PUBLIC_NAMES) publicNames.delete(publicNames.keys().next().value!)
30
+ return open
31
+ }
32
+
33
+ // The npm packages anyone may read (isPublicNpmPackage), a few asked at a
34
+ // time, within PUBLIC_BUDGET_MS, a third of the audit's: a scoped one npm
35
+ // hasn't told of by then is taken as private, asked of npm alone, for the
36
+ // audit to keep the rest of its time.
37
+ const PUBLIC_BUDGET_MS = 10_000
38
+ async function publicNpmNames(packages: Package[], signal: AbortSignal): Promise<Set<string>> {
39
+ const names = [...new Set(packages.filter(pkg => pkg.ecosystem === 'npm').map(pkg => pkg.name))]
40
+ const open = new Set<string>()
41
+ const budget = new AbortController()
42
+ const timer = setTimeout(() => { budget.abort(new Error('public-check-budget')) }, PUBLIC_BUDGET_MS)
43
+ const asking = AbortSignal.any([signal, budget.signal])
44
+ try {
45
+ for (let at = 0; at < names.length; at += PUBLIC_AT_ONCE) {
46
+ const chunk = names.slice(at, at + PUBLIC_AT_ONCE)
47
+ const checked = await Promise.all(chunk.map(name => isPublicNpmPackage(name, asking)))
48
+ for (const [i, name] of chunk.entries()) if (checked[i]) open.add(name)
49
+ }
50
+ } finally { clearTimeout(timer) }
51
+ return open
52
+ }
5
53
 
6
54
  function advisoryGithubClient(token: string | null, signal: AbortSignal): Client {
7
55
  const authenticated = createClient({ token, userAgent: 'deepview-triage' })
8
- if (token === null) return authenticated
9
56
  let client = authenticated
10
57
  return {
11
58
  ...authenticated,
12
59
  async listRepoAdvisories(options) {
13
60
  const current = client
14
61
  try { return await current.listRepoAdvisories(options) } catch (error) {
15
- if (current !== authenticated || !(error instanceof HttpError) || error.status !== 401) throw error
62
+ if (token === null || current !== authenticated || !(error instanceof HttpError) || error.status !== 401) throw error
16
63
  signal.throwIfAborted()
17
64
  // Published advisories remain readable after a token is revoked. Reuse
18
65
  // anonymous access for subsequent repositories in this audit as well.
19
66
  if (client === authenticated) client = createClient({ token: null, userAgent: 'deepview-triage' })
20
- return client.listRepoAdvisories(options)
67
+ return await client.listRepoAdvisories(options)
21
68
  }
22
69
  },
23
70
  }
@@ -25,11 +72,15 @@ function advisoryGithubClient(token: string | null, signal: AbortSignal): Client
25
72
 
26
73
  // The managed caller supplies its user's token when available; public shares
27
74
  // remain anonymous. Upstream sends this credential only to GitHub and returns
28
- // published advisories, matching versions across npm, OSV and repository rows.
29
- // `cache` keeps each repository's listing (upstream-cache.ts) across audits.
75
+ // published advisories, matching versions across npm, OSV and repository rows:
76
+ // OSV's for npm packages anyone may read too, the malicious packages' records
77
+ // among them, which npm's registry hasn't.
78
+ // `cache` keeps each repository's listing (upstream-cache.ts) across audits;
79
+ // `github`, a client of the caller's, asks GitHub in place of one with
80
+ // `githubToken`.
30
81
  export async function fetchBundleAdvisories(packages: Package[], signal: AbortSignal,
31
- { debug = false, repoAdvisories = false, details = false, githubToken = null, cache }: {
32
- debug?: boolean; repoAdvisories?: boolean; details?: boolean; githubToken?: string | null; cache?: CacheStore | undefined
82
+ { debug = false, repoAdvisories = false, details = false, githubToken = null, github, cache }: {
83
+ debug?: boolean; repoAdvisories?: boolean; details?: boolean; githubToken?: string | null; github?: Client; cache?: CacheStore | undefined
33
84
  } = {}): Promise<
34
85
  { status: 200; body: Advisory[] } | { status: 502; body: { error: string } }
35
86
  > {
@@ -43,12 +94,18 @@ export async function fetchBundleAdvisories(packages: Package[], signal: AbortSi
43
94
  onAbort = () => reject(signal.reason)
44
95
  signal.addEventListener('abort', onAbort, { once: true })
45
96
  })
97
+ // npm packages anyone may read are asked of OSV too (upstream's `osv`),
98
+ // for the malicious packages' records npm's registry hasn't; a private
99
+ // one's name never leaves for OSV, as it never does for Socket. A package
100
+ // is wholly in one ask or the other, so upstream's order holds.
101
+ const open = await Promise.race([publicNpmNames(packages, signal), deadline])
102
+ const toOsv = (pkg: Package) => pkg.ecosystem === 'npm' && open.has(pkg.name)
46
103
  // Kept in the caller's store, or without one in upstream's disk cache,
47
104
  // where the server set one (auditCache).
48
- const result = await Promise.race([
49
- advisories(packages, { github: advisoryGithubClient(githubToken, signal), repoAdvisories, details, ...(cache && { cache }) }), deadline,
50
- ])
51
- return { status: 200, body: result }
105
+ const options = { github: github ?? advisoryGithubClient(githubToken, signal), repoAdvisories, details, ...(cache && { cache }) }
106
+ const asked = [{ part: packages.filter(toOsv), osv: true }, { part: packages.filter(pkg => !toOsv(pkg)), osv: false }]
107
+ const result = await Promise.race([Promise.all(asked.map(({ part, osv }) => part.length === 0 ? Promise.resolve([]) : advisories(part, { ...options, osv }))), deadline])
108
+ return { status: 200, body: result.flat().toSorted((a, b) => order(a.ecosystem, b.ecosystem) || order(a.name, b.name)) }
52
109
  } catch (error) {
53
110
  if (debug) console.warn('managed: advisories failed:', error)
54
111
  return { status: 502, body: { error: 'upstream-unavailable' } }
@@ -3,8 +3,10 @@
3
3
  import { Buffer } from 'node:buffer'
4
4
  import { brotliDecompress } from 'node:zlib'
5
5
  import { promisify } from 'node:util'
6
- import { BUNDLE_METADATA_VERSION, type BundleDetails, createBundleMetadata, createBundleSummary, parseBundleContents } from '../common/bundle-metadata.js'
6
+ import { bundleEdges } from '@preventive/sourcemap/edges.js'
7
+ import { BUNDLE_METADATA_VERSION, type BundleDetails, createBundleMetadata, createBundleSummary, parseBundleContents, parseBundleMetadata } from '../common/bundle-metadata.js'
7
8
  import { bundleReasons } from '../common/bundle-reasons.js'
9
+ import { sourcemapEdges } from '../common/bundle-sourcemap.js'
8
10
  import { type BundleAdvisoryInventory, bundleAdvisoryInventory } from './bundle-advisory-inventory.ts'
9
11
  import { decodeUtf8 } from '../common/utf8.js'
10
12
  import type { OpenedBlob } from './blob-store.ts'
@@ -19,7 +21,7 @@ const MAX_DECODED_BYTES = 512 * 1024 * 1024
19
21
  export const MAX_PACKAGE_INVENTORY_BYTES = 1024 * 1024
20
22
  export type BundleCachePart = 'metadata' | 'contents'
21
23
  export type BundleCacheRecord = Pick<ManagedBundle, 'id' | 'integrity' | 'filename' | 'kind' | 'byteSize'>
22
- export interface BundleSummary { files: number; codeFiles: number; lines: number; commit?: string }
24
+ export interface BundleSummary { files: number; codeFiles: number; lines: number; commit?: string; stasisVersion?: number; versionedPackages?: number }
23
25
 
24
26
  export async function readBundleDetails(record: BundleCacheRecord, store: BundleStore) {
25
27
  const bytes = await store.get(record.id, record.kind)
@@ -76,15 +78,36 @@ function encodePackageInventory(details: BundleDetails): Buffer {
76
78
  return Buffer.from(parts.join(''))
77
79
  }
78
80
 
81
+ // What a catalog sends of a bundle before it opens. A Stasis bundle's
82
+ // `versionedPackages` counts the name@version pairs its unscoped advisory
83
+ // audit covers, audited or skipped: with none, Advisories has nothing to show.
84
+ export function bundleSummary(details: BundleDetails, metadata?: Parameters<typeof createBundleSummary>[1]): BundleSummary {
85
+ const summary = createBundleSummary(details, metadata)
86
+ if (details.kind !== 'stasis') return summary
87
+ const { packages, skipped } = bundleAdvisoryInventory(details)
88
+ return { ...summary, versionedPackages: packages.reduce((count, pkg) => count + pkg.versions.length, 0) + skipped.length }
89
+ }
90
+
91
+ // A sourcemap's edges, read with the parser (edges.js) that the client goes
92
+ // without: every map's, where the client reads Metro's alone. Without it,
93
+ // the metadata keeps what the client would read. A map with no
94
+ // sourcesContent has no source to read them from.
95
+ function readSourcemapEdges(details: BundleDetails) {
96
+ if (details.kind !== 'sourcemap' || !details.map?.files.some(file => file.content !== null)) return
97
+ try { details.edges = sourcemapEdges(details.map, bundleEdges) }
98
+ catch (err) { console.warn('managed: sourcemap edges failed:', err) }
99
+ }
100
+
79
101
  async function build(record: BundleCacheRecord, storage: BundleCacheStorage, db: ManagedDb, store: BundleStore) {
80
102
  const details = await readBundleDetails(record, store)
81
103
  if (!details) throw new Error('Bundle bytes unavailable')
104
+ readSourcemapEdges(details)
82
105
  const metadata = { ...await createBundleMetadata(details), id: record.id, filename: record.filename }
83
106
  const body = await encodeBrotli(Buffer.from(JSON.stringify(metadata)))
84
107
  if (!(await db.getBundle(record.id))) throw new Error('Bundle deleted')
85
108
  await storage.put(record.id, filename, body)
86
109
  if (record.kind === 'stasis') await storage.put(record.id, packagesFilename, encodePackageInventory(details))
87
- const summary = createBundleSummary(details, metadata)
110
+ const summary = bundleSummary(details, metadata)
88
111
  await storage.put(record.id, SUMMARY_FILENAME, Buffer.from(JSON.stringify(summary)))
89
112
  // A different instance may have deleted the row while these writes ran.
90
113
  // Reconcile after publishing so its cleanup cannot be undone by us.
@@ -102,7 +125,7 @@ export function createBundleCache(storage: BundleCacheStorage, db: ManagedDb, st
102
125
  const summaries = createBundleSummaryCache(storage, async record => {
103
126
  const details = await readBundleDetails(record, store)
104
127
  if (!details) throw new Error('Bundle bytes unavailable')
105
- return createBundleSummary(details)
128
+ return bundleSummary(details)
106
129
  }, id => db.getBundle(id))
107
130
  let queue = Promise.resolve()
108
131
  async function ensure(record: BundleCacheRecord): Promise<void> {
@@ -137,6 +160,16 @@ export function createBundleCache(storage: BundleCacheStorage, db: ManagedDb, st
137
160
  }
138
161
  return openCached(record, filename)
139
162
  },
163
+ // A sourcemap's edges as its metadata keeps them, read with the parser
164
+ // once, for a derivative that reads the bundle itself (report previews).
165
+ async sourcemapEdges(record: ManagedBundle): Promise<BundleDetails['edges']> {
166
+ const cached = await openCached(record, filename)
167
+ const chunks: Buffer[] = []
168
+ try { for await (const chunk of cached.stream) chunks.push(Buffer.from(chunk)) }
169
+ finally { cached.stream.destroy() }
170
+ const decoded = await decompress(Buffer.concat(chunks), { maxOutputLength: MAX_DECODED_BYTES })
171
+ return parseBundleMetadata(JSON.parse(decodeUtf8(decoded)), record.integrity).edges
172
+ },
140
173
  async advisoryInventory(record: ManagedBundle, reason = ''): Promise<BundleAdvisoryInventory | null | undefined> {
141
174
  if (record.kind !== 'stasis') return { packages: [], skipped: [] }
142
175
  const cached = await openCached(record, packagesFilename)