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

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 (177) 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-45WIN3SW.js +1 -0
  46. package/out/chunk-4XCDFGDP.js +10 -0
  47. package/out/chunk-6C76GAXK.js +1 -0
  48. package/out/chunk-7ZKDKGAH.js +1 -0
  49. package/out/chunk-ABW5FVSN.js +1 -0
  50. package/out/chunk-C4Z6ZBXV.js +10 -0
  51. package/out/chunk-CMQA4DM6.js +1 -0
  52. package/out/chunk-CVVJAXVY.js +2 -0
  53. package/out/chunk-DIJJNTRH.js +10 -0
  54. package/out/chunk-F4OMDNEY.js +1 -0
  55. package/out/chunk-GJ4IUCNS.js +1 -0
  56. package/out/chunk-GMFXFEUA.js +2 -0
  57. package/out/chunk-GNXOKIGP.js +80 -0
  58. package/out/chunk-GZ4MS3TE.js +13 -0
  59. package/out/chunk-HIV7SVI7.js +1 -0
  60. package/out/chunk-IU3CCINR.js +3 -0
  61. package/out/chunk-JVBB4LZX.js +1 -0
  62. package/out/chunk-K3X5EJBQ.js +2 -0
  63. package/out/chunk-KEX2ATEW.js +2 -0
  64. package/out/chunk-KYCNJVB7.js +1 -0
  65. package/out/chunk-NRPC74AM.js +1 -0
  66. package/out/chunk-OOJM4CTU.js +1 -0
  67. package/out/chunk-PNHBZGDD.js +1 -0
  68. package/out/chunk-QSLWGJBQ.js +223 -0
  69. package/out/chunk-R5O6FAW4.js +2 -0
  70. package/out/chunk-RFZQQXSG.js +17 -0
  71. package/out/chunk-TINZZKP5.js +33 -0
  72. package/out/chunk-TUII34K2.js +1 -0
  73. package/out/chunk-TZ5KB2Y5.js +10 -0
  74. package/out/chunk-UNTRGE26.js +2 -0
  75. package/out/chunk-UYAXVRVL.js +1 -0
  76. package/out/chunk-V3ME6FI5.js +2 -0
  77. package/out/chunk-V5O7M2YT.js +1 -0
  78. package/out/chunk-VIHGO77Y.js +1 -0
  79. package/out/chunk-W4J3MGLB.js +1 -0
  80. package/out/chunk-WGG7KQUH.js +1 -0
  81. package/out/chunk-XNGEQO7Y.js +2 -0
  82. package/out/chunk-XRWAV3DX.js +7 -0
  83. package/out/chunk-XYDGU6BM.js +51 -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 +89 -7
  97. package/risk/files/characters.js +196 -0
  98. package/risk/files/hosts.js +205 -0
  99. package/risk/files/readability.js +171 -0
  100. package/risk/files/runs-code.js +358 -0
  101. package/risk/files/script.js +114 -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 +228 -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 +34 -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 +74 -12
  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-app.ts +9 -3
  140. package/server-managed/github-errors.ts +21 -2
  141. package/server-managed/github-issue-oauth.ts +5 -6
  142. package/server-managed/github-metadata.ts +115 -0
  143. package/server-managed/github-oauth.ts +38 -2
  144. package/server-managed/http-response.ts +26 -0
  145. package/server-managed/http.ts +952 -53
  146. package/server-managed/index.ts +11 -5
  147. package/server-managed/kept.ts +28 -0
  148. package/server-managed/locked-packages.ts +85 -0
  149. package/server-managed/lru.ts +30 -0
  150. package/server-managed/npm-insights.ts +440 -0
  151. package/server-managed/npm-kept.ts +65 -0
  152. package/server-managed/npm-loads.ts +167 -0
  153. package/server-managed/npm-manifest.ts +115 -0
  154. package/server-managed/npm-packages.ts +378 -0
  155. package/server-managed/npm-provenance.ts +123 -0
  156. package/server-managed/npm-risk.ts +177 -0
  157. package/server-managed/pretty-print.ts +352 -0
  158. package/server-managed/public-workspace.ts +78 -7
  159. package/server-managed/rename-diff-worker.js +26 -0
  160. package/server-managed/rename-diff.ts +270 -0
  161. package/server-managed/report-sources.ts +13 -2
  162. package/server-managed/repository-browser.ts +19 -5
  163. package/server-managed/repository-risk.ts +418 -0
  164. package/server-managed/session.ts +16 -2
  165. package/server-managed/sql.ts +5 -2
  166. package/server-managed/storage-encryption.ts +5 -3
  167. package/server-managed/storage-payload.ts +4 -0
  168. package/server-managed/storage-stores.ts +2 -0
  169. package/server-managed/storage.ts +6 -3
  170. package/server-managed/team-app.ts +94 -0
  171. package/server-managed/team-feed.ts +1 -1
  172. package/server-managed/team-npm-scopes.ts +77 -0
  173. package/server-managed/type-only-worker.js +19 -0
  174. package/server-managed/type-only.ts +103 -0
  175. package/server-managed/uploads.ts +25 -4
  176. package/server-managed/upstream-cache.ts +147 -5
  177. package/server-managed/view-as.ts +58 -0
@@ -0,0 +1,46 @@
1
+ // A file's stamps: what risk reads of one file (the readers beside this), and
2
+ // what of it calls for a look, as a list of files tags it and orders it by:
3
+ // each `{ name, severity }`, its tag's text and how severe it is (SEVERITIES).
4
+ import { READABILITY, npmFileReadability, npmLabelMark, npmLargeCode, npmReadabilityLabels, npmTaggedCategory } from './readability.js'
5
+ import { npmExfiltration, npmHostSeverity, npmHostTags, npmHosts } from './hosts.js'
6
+ import { npmRunsCode, npmRunsCodeFlags } from './runs-code.js'
7
+
8
+ // How severe a stamp or a finding is, the most first: `critical` red,
9
+ // `high` a warning's orange, `moderate` yellow, `low`, and a `notice`, under
10
+ // a low one.
11
+ export const SEVERITIES = ['critical', 'high', 'moderate', 'low', 'notice']
12
+
13
+ // A label's mark (readability.js READABILITY) as a severity: a warning's
14
+ // orange, or a notice.
15
+ const severityOf = mark => mark === 'warn' ? 'high' : 'notice'
16
+
17
+ // A file's stamps, from what was read of it: what its code does beyond
18
+ // itself (runs-code.js npmRunsCodeFlags), a warning's or, for the whole
19
+ // environment read out and publishing, critical; the services it names
20
+ // (hosts.js), a stamp a tag, at the tag's severity (Exfiltration critical,
21
+ // Remote high, IP lookup moderate); a large code file, a notice; an
22
+ // obfuscator's fingerprint, a warning's; and its control characters, long
23
+ // lines and category, by their marks, but for what reads as most files do
24
+ // (UTF-8, ASCII) and a source map.
25
+ function npmStampsOf({ path, bytes, readability, runsCode, exfiltration }) {
26
+ const category = npmTaggedCategory(readability)
27
+ const listed = category !== 'ascii' && category !== 'map' && READABILITY.get(category).mark
28
+ return [...npmRunsCodeFlags(runsCode),
29
+ ...npmHostTags(exfiltration).map(([tag, services]) => ({ name: tag, severity: npmHostSeverity(tag, services) })),
30
+ ...npmLargeCode(path, bytes) ? [{ name: 'Large', severity: 'notice' }] : [],
31
+ ...readability.obfuscation ? [{ name: 'Obfuscated', severity: 'high' }] : [],
32
+ ...npmReadabilityLabels(readability).filter(label => label === 'controls' || label === 'long')
33
+ .map(label => ({ name: READABILITY.get(label).tag, severity: severityOf(npmLabelMark(readability, label)) })),
34
+ ...listed ? [{ name: READABILITY.get(category).tag, severity: severityOf(listed) }] : []]
35
+ }
36
+
37
+ // Everything risk reads of a file (`path`, its `text`, null for one that
38
+ // isn't, and its size in `bytes`): how it reads (npmFileReadability), what
39
+ // its code does beyond itself (npmRunsCode), the services it names under the
40
+ // hosts' tags (npmExfiltration) and the hosts its code names (npmHosts), and its
41
+ // `stamps` (npmStampsOf).
42
+ export function npmFileRisk(path, text, bytes) {
43
+ const readability = npmFileReadability(path, text)
44
+ const exfiltration = npmExfiltration(path, text), runsCode = npmRunsCode(path, text)
45
+ return { readability, runsCode, exfiltration, hosts: npmHosts(path, text), stamps: npmStampsOf({ path, bytes, readability, runsCode, exfiltration }) }
46
+ }
@@ -0,0 +1,142 @@
1
+ // Hand-written against index.js; a change to either belongs with the other.
2
+ import type { CacheStore } from '@preventive/upstream/npm.js'
3
+
4
+ // How severe a stamp or a finding is, the most first: `critical` red, `high`
5
+ // a warning's orange, `moderate` yellow, `low`, and a `notice`.
6
+ export type Severity = 'critical' | 'high' | 'moderate' | 'low' | 'notice'
7
+
8
+ // The severities, the most severe first.
9
+ export const SEVERITIES: readonly Severity[]
10
+
11
+ // How old a version must be to be trusted unless said: two weeks, in ms.
12
+ export const DEFAULT_MINIMUM_RELEASE_AGE: number
13
+
14
+ // npm has no such package or version (`package-not-found`,
15
+ // `version-not-found`), a tarball unpacks past risk's bounds
16
+ // (`package-too-large`), npm asked to be asked later (`upstream-busy`, a 429,
17
+ // for a caller to wait on and ask again), or npm couldn't be asked
18
+ // (`upstream-unavailable`), answered past them (`upstream-too-large`) or
19
+ // answered what can't be read (`upstream-invalid`). `message` tells which
20
+ // request and why.
21
+ export class NpmRiskError extends Error {
22
+ code: string
23
+ }
24
+
25
+ export interface AssessNpmOptions {
26
+ // The version to compare with; true for the one of the package's versions
27
+ // most likely free of what an attack injects, as risk picks it by npm's
28
+ // document (risk/npm/old-version.js), none where none is below it; without
29
+ // one, everything of the version is new.
30
+ oldVersion?: string | true | null
31
+ // Compare all files with an empty package, for an initial dependency review.
32
+ fromEmpty?: boolean
33
+ // Younger than this, in ms, a version is `too-new`; 0 for none, as a
34
+ // caller's own scopes take. Two weeks unless given.
35
+ minimumReleaseAge?: number
36
+ // Whether the package is flagged as few download it (`popularity`); false
37
+ // for none, as a caller's own scopes take, its downloads then not asked.
38
+ // True unless given.
39
+ popularity?: boolean
40
+ // Where what is read is kept, by type (risk/npm/registry.js and assess.js
41
+ // name each): `npm/tarballs`, `risk/npm/versions`, `risk/npm/packages`,
42
+ // `risk/npm/old-versions`, `risk/npm/public`, `risk/npm/downloads`,
43
+ // `risk/npm/advisories` and `risk/npm/assessments`.
44
+ cache?: CacheStore | false
45
+ // Asks for a package's whole document in risk's stead, as the managed
46
+ // server does within its bounds: null where npm has none for this asker.
47
+ // `authorized` whether it may be asked with the caller's npm token: the
48
+ // package assessed, not its dependencies.
49
+ packument?: (name: string, options: { authorized: boolean; signal: AbortSignal | undefined }) => Promise<Record<string, unknown> | null>
50
+ // Reads a version's tarball, gzipped, in place of upstream's getTarball, as
51
+ // the managed server does within its bounds: `version` the one assessed or
52
+ // `oldVersion`, that one only where its files could change what is found.
53
+ // What it throws is thrown as it is.
54
+ tarball?: (name: string, version: string, options: { signal: AbortSignal | undefined }) => Promise<Uint8Array>
55
+ now?: number
56
+ signal?: AbortSignal
57
+ // Whether npm's downloads are asked for: the package's popularity, and its
58
+ // new dependencies' tiers and ages. True unless given.
59
+ downloads?: boolean
60
+ // Whether its new dependencies' tiers and ages are asked for, a request or
61
+ // two of npm each, where `downloads` is. True unless given.
62
+ dependencies?: boolean
63
+ // Whether npm's advisories of the version are asked for, an advisory of
64
+ // malware naming it a `malware` finding, as its package taken down by npm
65
+ // is. True unless given.
66
+ advisories?: boolean
67
+ // Whether OSV's records of malware in the version are asked for too, where
68
+ // `advisories` are: false for a private package, whose name OSV is never
69
+ // to be sent. True unless given.
70
+ osv?: boolean
71
+ // Whether a package is the caller's own, as its own scopes take: a new
72
+ // dependency on one is told no tier nor age, nor is npm asked of it. None
73
+ // unless given.
74
+ owned?: (name: string) => boolean | Promise<boolean>
75
+ }
76
+
77
+ // What calls for a look, by `id`, with what it found beside.
78
+ export interface Finding {
79
+ id: string
80
+ severity: Severity
81
+ [found: string]: unknown
82
+ }
83
+
84
+ // A stamp on a file, as a list of files tags it.
85
+ export interface Stamp {
86
+ name: string
87
+ severity: Severity
88
+ }
89
+
90
+ export interface FileRisk {
91
+ stamps: Stamp[]
92
+ readability: Record<string, unknown>
93
+ runsCode: Record<string, number[]> | null
94
+ exfiltration: Record<string, number[]> | null
95
+ hosts: Record<string, number> | null
96
+ }
97
+
98
+ export interface DependencyRisk {
99
+ key: string
100
+ name: string
101
+ kind: 'peer' | 'optional' | null
102
+ range?: string
103
+ from?: string
104
+ to?: string
105
+ opens: string | null
106
+ flags: { check: string; label: string; tooltip: string }[]
107
+ tier?: number | null
108
+ level?: string
109
+ // Its last whole month's downloads, by what its tier goes by: none where
110
+ // npm counts none of them.
111
+ month?: { total: number; from: string }
112
+ // Whether npm counts none of its downloads, as of a package published
113
+ // the day before; unpopular all the same.
114
+ uncounted?: true
115
+ created?: string
116
+ gap?: number
117
+ }
118
+
119
+ export interface NpmAssessment {
120
+ name: string
121
+ version: string
122
+ // The version compared with, as given or as risk picked it; null for none.
123
+ oldVersion: string | null
124
+ published: string | null
125
+ // A version npm removed, as it removes every one it finds malware in, told
126
+ // of by its `malware` finding alone, with no files nor dependencies.
127
+ removed?: true
128
+ findings: Finding[]
129
+ files: Record<string, FileRisk>
130
+ // Against `oldVersion`, each it holds too, flagged against none (`held`).
131
+ dependencies: { added: DependencyRisk[]; changed: DependencyRisk[]; held?: DependencyRisk[] }
132
+ }
133
+
134
+ export function assessNpm(name: string, version: string, options?: AssessNpmOptions): Promise<NpmAssessment>
135
+
136
+ // Whether an advisory is of malware rather than a vulnerability: OSV's of a
137
+ // malicious package (`MAL-…`), or one such a record names (its `aliases`),
138
+ // CWE-506, embedded malicious code, whatever its title, or a title naming
139
+ // malware.
140
+ export function npmMalwareAdvisory(advisory: { id?: string | undefined; aliases?: readonly string[] | undefined; cwe?: readonly string[] | undefined; title?: string | undefined }): boolean
141
+
142
+ export function npmFileRisk(path: string, text: string | null, bytes?: number): unknown
package/risk/index.js ADDED
@@ -0,0 +1,7 @@
1
+ // What calls for a look at a package version, for a reviewer: risk's
2
+ // heuristics, apart from any view of them. See README.md.
3
+ export { assessNpm } from './npm/assess.js'
4
+ export { npmMalwareAdvisory } from './npm/malware.js'
5
+ export { DEFAULT_MINIMUM_RELEASE_AGE } from './npm/release.js'
6
+ export { NpmRiskError } from './npm/registry.js'
7
+ export { SEVERITIES, npmFileRisk } from './files/stamps.js'
@@ -0,0 +1,219 @@
1
+ // A package version's risk, as the managed server asks it: what calls for a
2
+ // look at it, against the version before it where one is given or risk picks
3
+ // one (old-version.js), each file's stamps, and its dependencies', from npm's
4
+ // registry, kept in the caller's cache (registry.js) as it goes.
5
+ import { npmBaseFilesTell, npmCompareFindings } from './compare.js'
6
+ import { npmCombinedFindings, npmHeldDependencyFindings } from './combined.js'
7
+ import { npmHeldFindings, npmVersionFindings } from './held.js'
8
+ import { npmVersionFileRisk } from './files.js'
9
+ import { npmDependencyChanges, npmFlagDependencyChanges } from './dependencies.js'
10
+ import { npmDownloadMonths, npmSettledDownloads, npmTierLevel } from './downloads.js'
11
+ import { NpmRiskError, npmDownloads, npmPackageFacts, npmPackageReleases, npmPublicFacts, npmVersionAdvisories, npmVersionFiles } from './registry.js'
12
+ import { npmMalwareFinding } from './malware.js'
13
+ import { npmKnownScope } from './manifest.js'
14
+ import { DEFAULT_MINIMUM_RELEASE_AGE, npmAccountStanding } from './release.js'
15
+ import { npmAddedDependency } from './new-dependency.js'
16
+
17
+ // Which reading of a version an assessment kept (`risk/npm/assessments`) is:
18
+ // one more whenever a heuristic changes what it finds, so one kept by an
19
+ // older reading is read anew.
20
+ const RISK_VERSION = 12
21
+
22
+ // How long an assessment kept may be as JSON, and what risk reads of either
23
+ // version's files: a package past it, whose files name thousands of hosts or
24
+ // do what they run on thousands of lines each, is refused as too large as its
25
+ // files are read, before more of them is held (filesRisk), and the assessment
26
+ // once told, rather than kept and answered. A package's files tell a few
27
+ // hundred characters each, a few MiB for the most files npm's viewer reads.
28
+ const MAX_ASSESSMENT_LENGTH = 16 * 1024 * 1024
29
+ const tooLarge = what => new NpmRiskError('package-too-large', `what risk tells of ${what} is past ${MAX_ASSESSMENT_LENGTH} characters`)
30
+
31
+ // A value as JSON holds it: a Map as an object, its keys as strings.
32
+ const plain = value => value instanceof Map ? Object.fromEntries([...value].map(([key, item]) => [key, plain(item)]))
33
+ : Array.isArray(value) ? value.map(plain)
34
+ : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, plain(item)])) : value
35
+
36
+ // A tier among npm's packages as an assessment says it: `{ tier, level }`, the
37
+ // rank a package is among (null past the last, unpopular) and its tint
38
+ // (downloads.js npmTierLevel).
39
+ const tierOf = tier => ({ tier: Number.isFinite(tier) ? tier : null, level: npmTierLevel(tier) })
40
+
41
+ // Each of a version's files' stamps and what risk read of it (stamps.js
42
+ // npmFileRisk), by path, as JSON holds them: read a file at a time and given
43
+ // up on, as too large, once they are past MAX_ASSESSMENT_LENGTH as JSON
44
+ // altogether, so that no more of them is held than that.
45
+ function filesRisk(version, what) {
46
+ let length = 0
47
+ return Object.fromEntries(version.json.sources.map(path => {
48
+ const { readability, runsCode, exfiltration, hosts, stamps } = npmVersionFileRisk(version, path)
49
+ const risk = plain({ stamps, readability, runsCode, exfiltration, hosts })
50
+ length += path.length + JSON.stringify(risk).length
51
+ if (length > MAX_ASSESSMENT_LENGTH) throw tooLarge(what)
52
+ return [path, risk]
53
+ }))
54
+ }
55
+
56
+ // What of an assessment holds whatever the time, of the open version
57
+ // (`other`) against the one compared with (`base`), or alone without one,
58
+ // each as compare.js takes a version, as JSON holds it: what it holds,
59
+ // always (held.js npmHeldFindings, and its dependencies against none,
60
+ // combined.js npmHeldDependencyFindings), and against `base` what its files,
61
+ // dependencies, install scripts, publisher and provenance call for anew
62
+ // (compare.js npmCompareFindings), folded into what it holds, never lowering
63
+ // it (combined.js npmCombinedFindings); each file's stamps and what risk read
64
+ // of it (filesRisk); and its dependencies added and changed, each with its
65
+ // flags (dependencies.js), against `base` each it holds too (`held`). Its own findings are told fresh
66
+ // (npmAssessmentOf). The managed server's assessments keep it (`lasting`),
67
+ // and a test can tell what one says of versions it holds. Each publisher's
68
+ // standing on the package is told where `standing` is given (release.js
69
+ // npmAccountStanding, of the open version).
70
+ export const npmLastingRisk = (other, base = null, standing = null) => lastingOf(other, base, filesRisk(other, `${other.npm.name}@${other.npm.version}`), standing)
71
+
72
+ // npmLastingRisk's, the open version's files read within bounds (`files`,
73
+ // filesRisk's), as the one compared with's are where `base` has them, before
74
+ // the findings read them all.
75
+ function lastingOf(other, base, files, standing) {
76
+ if (base?.json) filesRisk(base, `${base.npm.name}@${base.npm.version}`)
77
+ const all = npmFlagDependencyChanges(npmDependencyChanges(undefined, other.npm.manifest), null, other)
78
+ const held = [...npmHeldFindings(other), ...npmHeldDependencyFindings(all)]
79
+ const { added, changed } = base ? npmFlagDependencyChanges(npmDependencyChanges(base.npm.manifest, other.npm.manifest), base, other) : all
80
+ const findings = base ? npmCombinedFindings(held, npmCompareFindings(base, other, { added, changed }, standing)) : held
81
+ const kept = { findings: plain(findings), files, dependencies: plain({ added, changed, ...base ? { held: all.added } : {} }) }
82
+ if (JSON.stringify(kept).length > MAX_ASSESSMENT_LENGTH) throw tooLarge(`${other.npm.name}@${other.npm.version}`)
83
+ return kept
84
+ }
85
+
86
+ // What of an assessment holds whatever the time (npmLastingRisk), kept once a
87
+ // reading and pair, its versions' files read from npm only where it isn't:
88
+ // the open version's first, then the one compared with's only where they
89
+ // could change what is found (compare.js npmBaseFilesTell), that one else
90
+ // told by what npm's document of it says alone; and where another account
91
+ // published it, each one's standing on the package (release.js
92
+ // npmAccountStanding), by its versions as `facts` read them, or as its
93
+ // document is read again where they were kept. A standing changes as npm
94
+ // removes a version, its publisher then unknown, and never else, so such a
95
+ // pair is kept by how many npm has removed (`~<removals>`), read anew as it
96
+ // removes another.
97
+ async function lasting(name, version, oldVersion, facts, context, fromEmpty) {
98
+ const old = oldVersion === null ? null : facts.versions.get(oldVersion)
99
+ const publishers = [old, facts.versions.get(version)].map(fact => fact?.manifest.publisher)
100
+ const changed = publishers.every(Boolean) && publishers[0] !== publishers[1]
101
+ const key = `${RISK_VERSION}/${name}@${version}${fromEmpty ? '..(empty)' : oldVersion === null ? '' : `..${oldVersion}`}${changed ? `~${facts.removals}` : ''}`
102
+ const hit = context.cache ? await context.cache.read('risk/npm/assessments', key) : null
103
+ if (hit) return hit
104
+ const other = await npmVersionFiles(name, version, facts.versions.get(version), context)
105
+ const files = filesRisk(other, `${name}@${version}`)
106
+ const base = fromEmpty ? { npm: { name, version: '', manifest: {} }, json: { sources: [], sourcesContent: [] } } : old === null ? null : npmBaseFilesTell(other, old.manifest) ? await npmVersionFiles(name, oldVersion, old, context)
107
+ : { npm: { name, version: oldVersion, manifest: old.manifest, published: old.published }, json: null }
108
+ const standing = changed ? npmAccountStanding(facts.releases ?? await npmPackageReleases(name, context), other.npm.published) : null
109
+ const kept = lastingOf(other, base, files, standing)
110
+ if (context.cache) await context.cache.write('risk/npm/assessments', key, kept)
111
+ return kept
112
+ }
113
+
114
+ // How many dependencies added are told of at most, however many a version
115
+ // adds, as each costs npm a request or two: the rest are left as they are,
116
+ // their packages unknown.
117
+ const MAX_TOLD_DEPENDENCIES = 100
118
+
119
+ // Each dependency `added` that npm installs from its registry, up to
120
+ // MAX_TOLD_DEPENDENCIES of them, but for the caller's own packages (`owned`,
121
+ // as its own scopes take, few downloading them, as new as its releases),
122
+ // with what risk tells of its package
123
+ // (new-dependency.js npmAddedDependency): its tier, its last whole month's
124
+ // downloads by what its tier goes by where it has one (`month`, `{ total,
125
+ // from }`), `uncounted` where npm counts none of them, and `created` and `gap`
126
+ // where it is new since the version compared with (`published`, when that
127
+ // and the open version were); four asked at a time, whether each is the
128
+ // caller's own among them, none more once one fails or MAX_TOLD_DEPENDENCIES
129
+ // not the caller's own were taken, however many a version adds.
130
+ async function addedDependencies(added, published, context, owned) {
131
+ const told = [...added]
132
+ const asked = told.flatMap((row, at) => row.opens ? [at] : [])
133
+ let next = 0, taken = 0
134
+ const tell = async row => {
135
+ const downloads = await npmDownloads(row.opens, context)
136
+ let facts = npmAddedDependency({ downloads, published }, context.now)
137
+ if (facts.wanted) {
138
+ const listed = await npmPublicFacts(row.opens, context)
139
+ facts = npmAddedDependency({ downloads, listed: listed && { ...listed, private: false }, published }, context.now)
140
+ }
141
+ const settled = npmSettledDownloads(downloads, context.now)
142
+ const month = npmDownloadMonths(settled).at(-1)
143
+ return { ...row, ...facts.tier === null ? {} : { ...tierOf(facts.tier), ...settled ? month && { month: { total: month.total, from: month.from.toISOString().slice(0, 10) } } : { uncounted: true } },
144
+ ...facts.created ? { created: facts.created, gap: facts.gap } : {} }
145
+ }
146
+ await Promise.all(Array.from({ length: 4 }, async () => {
147
+ while (next < asked.length && taken < MAX_TOLD_DEPENDENCIES) {
148
+ const at = asked[next++]
149
+ try {
150
+ if (await owned(told[at].opens)) continue
151
+ if (taken >= MAX_TOLD_DEPENDENCIES) break
152
+ taken++
153
+ told[at] = await tell(told[at])
154
+ } catch (error) { next = asked.length; throw error }
155
+ }
156
+ }))
157
+ return told
158
+ }
159
+
160
+ // What calls for a look at `version` of package `name`, as JSON holds it:
161
+ // malware first (malware.js npmMalwareFinding: its package taken down by
162
+ // npm, and npm's advisories of malware naming it where `advisories`, true
163
+ // unless given false, has them asked, OSV's too where `osv` is, true unless
164
+ // given false, as a private package's name is never sent it); against `oldVersion` where given,
165
+ // true for the one of its versions most likely free of what an attack
166
+ // injects, as risk picks it by npm's document (old-version.js npmOldVersion;
167
+ // none where none is below it), the one it picked said as `oldVersion`
168
+ // (compare.js npmCompareFindings's order), else what it holds (held.js
169
+ // npmHeldFindings), a deprecation first, and last
170
+ // one younger than `minimumReleaseAge` (two weeks unless given; 0 for none, as
171
+ // a caller's own scopes take), and the package's tier past the top 20,000
172
+ // (`popularity`, held.js npmVersionFindings, unless `popularity` is given
173
+ // false, as a caller's own scopes take); `files`, each file's stamps and what risk read of it; and
174
+ // `dependencies`, those added and changed against it, each with its flags,
175
+ // an added one with its package's tier and age where `downloads` (true unless
176
+ // given false) has them asked, and `dependencies` (true unless given false)
177
+ // too, but where `owned` says its package is the caller's own, as its own scopes
178
+ // take (none unless given). `cache` keeps what is read (registry.js),
179
+ // `packument` asks for a package's whole document and `tarball` for a
180
+ // version's tarball in risk's stead (registry.js), `now` is when it is asked, `signal` gives up on what is
181
+ // still asked of npm. A version npm removed, as it removes every one it finds
182
+ // malware in, is told of by its malware alone (`removed`), nothing of it left
183
+ // to read. Throws an NpmRiskError (registry.js), its `code` saying why, where
184
+ // npm has no such package or version (nor malware explaining one removed),
185
+ // answers past risk's bounds, or can't be asked.
186
+ export async function assessNpm(name, version, { oldVersion = null, fromEmpty = false, minimumReleaseAge = DEFAULT_MINIMUM_RELEASE_AGE, popularity = true, cache, packument, tarball, now = Date.now(), signal, downloads = true, dependencies = true, advisories = true, osv = true, owned = () => false } = {}) {
187
+ const context = { cache, packument, tarball, now, signal }
188
+ const facts = await npmPackageFacts(name, version, oldVersion, context)
189
+ const old = facts.oldVersion
190
+ if (old !== null && !facts.versions.has(old)) throw new NpmRiskError('version-not-found', `npm has no ${name}@${old}`)
191
+ const malware = npmMalwareFinding({ takenDown: facts.takenDown, advisories: advisories ? await npmVersionAdvisories(name, version, osv, context) : [] })
192
+ if (!facts.versions.has(version)) {
193
+ if (!malware) throw new NpmRiskError('version-not-found', `npm has no ${name}@${version}`)
194
+ return { name, version, oldVersion: null, published: facts.removed.get(version), removed: true, findings: [malware], files: {}, dependencies: { added: [], changed: [] } }
195
+ }
196
+ const { published, manifest } = facts.versions.get(version)
197
+ const kept = await lasting(name, version, old, facts, context, fromEmpty)
198
+ const own = npmVersionFindings({ deprecated: facts.deprecated[version], published, minimumReleaseAge, now, known: Boolean(manifest.knownAccounts), scope: npmKnownScope(name),
199
+ provenance: Boolean(manifest.provenance), downloads: downloads && popularity ? await npmDownloads(name, context) : null })
200
+ const was = old === null ? null : facts.versions.get(old).published
201
+ const added = downloads && dependencies ? await addedDependencies(kept.dependencies.added, [was, published], context, owned) : kept.dependencies.added
202
+ return { name, version, oldVersion: old, published, ...npmAssessmentOf(kept, malware ? [malware, ...own] : own, added) }
203
+ }
204
+
205
+ // An assessment of what of it holds whatever the time (`kept`,
206
+ // npmLastingRisk's) and what is told fresh: the open version's own findings
207
+ // (`own`, held.js npmVersionFindings, and its malware), its malware and
208
+ // deprecation first and the rest last, its tier as JSON holds it (tierOf),
209
+ // and its dependencies `added`, as addedDependencies tells them. The managed
210
+ // server's (assessNpm), and a test's of versions it holds.
211
+ const FIRST = new Set(['malware', 'deprecated'])
212
+ export function npmAssessmentOf(kept, own, added = kept.dependencies.added) {
213
+ return {
214
+ findings: [...own.filter(({ id }) => FIRST.has(id)), ...kept.findings,
215
+ ...own.filter(({ id }) => !FIRST.has(id)).map(finding => finding.id === 'popularity' ? { ...finding, ...tierOf(finding.tier) } : finding)],
216
+ files: kept.files,
217
+ dependencies: { ...kept.dependencies, added },
218
+ }
219
+ }
@@ -0,0 +1,65 @@
1
+ // What calls for a look at a version against the one compared with, as one
2
+ // list: what it holds, always (held.js npmHeldFindings, and its dependencies
3
+ // against none, npmHeldDependencyFindings), as no change hides any of it; and
4
+ // what changed (compare.js npmCompareFindings), each change to what it holds
5
+ // folded into its held finding, never lowering it: its severity the higher of
6
+ // the two, the files, scripts or dependencies the change brought marked
7
+ // (`added`); the rest (a publisher changed, provenance lost, large code
8
+ // added, a file become unreadable, hosts named anew) as they are.
9
+ import { SEVERITIES } from '../files/stamps.js'
10
+ import { HOST_TAGS } from '../files/hosts.js'
11
+ import { npmDependencyFindings } from './compare.js'
12
+ import { npmHostFinding } from './held.js'
13
+
14
+ // A version's dependencies that call for a look whatever came before it
15
+ // (compare.js npmDependencyFindings against none, `dependencies` flagged
16
+ // against none): `held-non-registry-deps` and `held-unused-deps`.
17
+ export const npmHeldDependencyFindings = dependencies => npmDependencyFindings(dependencies, null).map(finding => ({ ...finding, id: `held-${finding.id}` }))
18
+
19
+ // The more severe of two severities (stamps.js SEVERITIES).
20
+ const higher = (a, b) => SEVERITIES[Math.min(SEVERITIES.indexOf(a), SEVERITIES.indexOf(b))]
21
+
22
+ // What a change to what a version holds marks of its held finding's rows,
23
+ // by the change's id: a file's what it runs, the services it names under a
24
+ // hosts' tag, and an obfuscator's fingerprint new to it (`added`); the
25
+ // scripts added or changed and what each version ran (`flagged`, `was`); a
26
+ // dependency taken from elsewhere than npm's registry anew, its spec before
27
+ // (`was`), or added that nothing imports (`added`). Code kept running beyond
28
+ // itself (`kept-runs-code`) marks nothing, its files held already.
29
+ const byPath = (rows, key) => new Map(rows.map(row => [row.path, row[key]]))
30
+ const marked = (held, rows) => ({ ...held, files: held.files.map(row => rows.has(row.path) ? { ...row, added: rows.get(row.path) } : row) })
31
+ const FOLDS = {
32
+ 'runs-code': ['held-runs-code', (held, change) => marked(held, byPath(change.files, 'added'))],
33
+ 'kept-runs-code': ['held-runs-code', held => held],
34
+ obfuscated: ['held-obfuscated', (held, change) => marked(held, byPath(change.files, 'obfuscation'))],
35
+ 'install-scripts': ['held-install-scripts', (held, change) => ({ ...held, flagged: change.flagged, was: [...change.was] })],
36
+ 'non-registry-deps': ['held-non-registry-deps', (held, change) => {
37
+ const was = new Map(change.dependencies.map(row => [row.name, row.was]))
38
+ return { ...held, dependencies: held.dependencies.map(row => was.has(row.name) ? { ...row, was: was.get(row.name), added: true } : row) }
39
+ }],
40
+ 'unused-deps': ['held-unused-deps', (held, change) => {
41
+ const added = new Set(change.dependencies.map(row => `${row.kind ?? ''}\0${row.name}`))
42
+ return { ...held, dependencies: held.dependencies.map(row => added.has(`${row.kind ?? ''}\0${row.name}`) ? { ...row, added: true } : row) }
43
+ }],
44
+ ...Object.fromEntries([...HOST_TAGS.keys()].map(npmHostFinding).map(id => [id, [`held-${id}`, (held, change) => marked(held, byPath(change.files, 'added'))]])),
45
+ }
46
+
47
+ // Who published the open version and how, ahead of what it holds, as a
48
+ // reviewer reads them; what else changed after it.
49
+ const FIRST = new Set(['publisher', 'provenance-lost'])
50
+
51
+ // What a version holds (`held`) with what changed against the one compared
52
+ // with (`changed`) folded in, as one list in the order a reviewer reads it: a
53
+ // publisher changed and provenance lost, what it holds, then the rest of what
54
+ // changed. A change whose held finding is missing is kept as it is.
55
+ export function npmCombinedFindings(held, changed) {
56
+ const combined = new Map(held.map(finding => [finding.id, finding]))
57
+ const rest = []
58
+ for (const change of changed) {
59
+ const [id, fold] = FOLDS[change.id] ?? []
60
+ const into = id && combined.get(id)
61
+ if (into) combined.set(id, { ...fold(into, change), severity: higher(into.severity, change.severity) })
62
+ else rest.push(change)
63
+ }
64
+ return [...rest.filter(({ id }) => FIRST.has(id)), ...combined.values(), ...rest.filter(({ id }) => !FIRST.has(id))]
65
+ }