@preventive/triage 1.0.0-alpha.44 → 1.0.0-alpha.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/common/bundle-sources.js +20 -1
  2. package/common/default-scan-models.ts +1 -1
  3. package/common/dependency-reviews.d.ts +0 -5
  4. package/common/dependency-reviews.js +0 -17
  5. package/common/managed/agent-tasks.ts +292 -0
  6. package/common/managed/github-agents.ts +92 -0
  7. package/common/managed/github-logins.ts +49 -0
  8. package/common/managed/roles.ts +7 -0
  9. package/common/managed/routes.js +88 -7
  10. package/common/managed/team-app.d.ts +5 -2
  11. package/common/managed/team-app.js +2 -2
  12. package/common/server-info.ts +3 -0
  13. package/common/workspace-app.js +6 -3
  14. package/out/brotli-fallback.js +3 -3
  15. package/out/chunk-2XUEZHIP.js +18 -18
  16. package/out/chunk-2ZDZWLFY.js +1 -0
  17. package/out/chunk-3PS4KWD2.js +10 -0
  18. package/out/chunk-5DHL5NG3.js +2 -0
  19. package/out/chunk-6HNXNIQY.js +52 -0
  20. package/out/chunk-7C7DWL7I.js +1 -0
  21. package/out/chunk-AFLZH7WG.js +1 -0
  22. package/out/chunk-BC2UCLFJ.js +1 -0
  23. package/out/chunk-C4Z6ZBXV.js +6 -6
  24. package/out/chunk-GDPCLSOS.js +2 -0
  25. package/out/chunk-GO7FT4OD.js +1 -0
  26. package/out/chunk-HMAJDVXN.js +1 -0
  27. package/out/chunk-I2MDGSQX.js +3 -0
  28. package/out/chunk-IYPY47MC.js +1 -0
  29. package/out/chunk-IZ7POWI3.js +18 -0
  30. package/out/chunk-JCIIBLKR.js +1 -1
  31. package/out/chunk-NPW52BM2.js +17 -0
  32. package/out/chunk-NRPC74AM.js +1 -1
  33. package/out/chunk-NV7ES4EW.js +1 -0
  34. package/out/chunk-OKRLLE5I.js +17 -0
  35. package/out/chunk-OOJM4CTU.js +1 -1
  36. package/out/chunk-PNHBZGDD.js +1 -1
  37. package/out/chunk-PTGDYLNS.js +36 -0
  38. package/out/chunk-PXK7EU4V.js +1 -0
  39. package/out/chunk-Q6MVYGYV.js +3 -0
  40. package/out/chunk-QB4CBOX2.js +1 -0
  41. package/out/chunk-QRD6GIG5.js +229 -0
  42. package/out/chunk-R3NZFXVR.js +1 -0
  43. package/out/chunk-R5O6FAW4.js +1 -1
  44. package/out/chunk-SXV2T5C4.js +80 -0
  45. package/out/chunk-TUII34K2.js +1 -1
  46. package/out/chunk-VFWMQ3LG.js +2 -0
  47. package/out/chunk-VH4XMZIW.js +2 -0
  48. package/out/chunk-WFYLMF6Z.js +10 -0
  49. package/out/chunk-WI7Z5A2T.js +6 -0
  50. package/out/chunk-WQ74ZBZF.js +7 -0
  51. package/out/chunk-XVBEOGOS.js +3 -0
  52. package/out/client-managed-import.js +3 -3
  53. package/out/client-managed.js +31 -35
  54. package/out/client-sync.js +11 -11
  55. package/out/compare.js +35 -0
  56. package/out/dialogs.js +51 -0
  57. package/out/github-issue-authorized.html +1 -1
  58. package/out/github-issue-authorized.js +1 -1
  59. package/out/graph.js +17 -17
  60. package/out/index.html +30 -34
  61. package/out/prism.js +14 -14
  62. package/out/scan.js +2 -0
  63. package/out/terminal.js +294 -293
  64. package/out/view.css +1 -1
  65. package/out/view.js +16 -107
  66. package/package.json +51 -6
  67. package/risk/actions/findings.js +156 -0
  68. package/risk/actions/resolve.js +219 -0
  69. package/risk/actions/scripts/commands.js +182 -0
  70. package/risk/actions/scripts/grade.js +113 -0
  71. package/risk/actions/scripts/packages.js +123 -0
  72. package/risk/actions/scripts/shell.js +165 -0
  73. package/risk/actions/uses.js +76 -0
  74. package/risk/actions/workflow.js +110 -0
  75. package/risk/files/hosts.js +6 -6
  76. package/risk/files/readability.js +23 -17
  77. package/risk/files/runs-code.js +18 -6
  78. package/risk/files/script.js +7 -3
  79. package/risk/files/stamps.js +8 -7
  80. package/risk/index.d.ts +68 -1
  81. package/risk/index.js +6 -2
  82. package/risk/npm/assess.js +1 -1
  83. package/risk/npm/compare.js +34 -10
  84. package/risk/npm/files.js +413 -2
  85. package/risk/npm/tarball.js +56 -73
  86. package/server-managed/agent-merges.ts +198 -0
  87. package/server-managed/app-agents.ts +30 -0
  88. package/server-managed/avatar-store.ts +1 -0
  89. package/server-managed/commit-coauthors.ts +83 -0
  90. package/server-managed/commit-lockfiles.ts +62 -0
  91. package/server-managed/db-methods.ts +29 -10
  92. package/server-managed/db-neon.ts +30 -2
  93. package/server-managed/db-schema.ts +9 -2
  94. package/server-managed/db.ts +7 -0
  95. package/server-managed/dependabot-access.ts +60 -0
  96. package/server-managed/dependabot-alerts.ts +185 -0
  97. package/server-managed/dependency-review-comments.ts +97 -0
  98. package/server-managed/dependency-reviews.ts +65 -15
  99. package/server-managed/github-accounts.ts +33 -0
  100. package/server-managed/github-actions.ts +71 -0
  101. package/server-managed/github-app.ts +23 -9
  102. package/server-managed/github-errors.ts +2 -0
  103. package/server-managed/github-link-oauth.ts +69 -0
  104. package/server-managed/github-links.ts +204 -0
  105. package/server-managed/github-oauth.ts +5 -2
  106. package/server-managed/github-users.ts +30 -0
  107. package/server-managed/http.ts +1122 -52
  108. package/server-managed/index.ts +6 -2
  109. package/server-managed/locked-packages.ts +37 -16
  110. package/server-managed/lockfile-scope.ts +49 -0
  111. package/server-managed/npm-delta-worker.js +23 -0
  112. package/server-managed/npm-delta.ts +173 -0
  113. package/server-managed/npm-insights.ts +20 -11
  114. package/server-managed/npm-kept.ts +51 -4
  115. package/server-managed/npm-loads.ts +20 -13
  116. package/server-managed/npm-packages.ts +24 -64
  117. package/server-managed/pull-comment-reviews.ts +161 -0
  118. package/server-managed/pull-dependencies.ts +280 -0
  119. package/server-managed/pull-dependency-reviews.ts +318 -0
  120. package/server-managed/pull-reviews.ts +205 -0
  121. package/server-managed/release-commits.ts +84 -0
  122. package/server-managed/repository-actions.ts +209 -0
  123. package/server-managed/repository-agents.ts +220 -0
  124. package/server-managed/repository-browser.ts +54 -13
  125. package/server-managed/repository-changes.ts +284 -0
  126. package/server-managed/repository-code.ts +52 -0
  127. package/server-managed/repository-dependency-reviews.ts +135 -0
  128. package/server-managed/repository-history.ts +445 -0
  129. package/server-managed/repository-issues.ts +181 -0
  130. package/server-managed/repository-pulse.ts +432 -0
  131. package/server-managed/repository-risk-read.ts +165 -0
  132. package/server-managed/repository-risk.ts +113 -105
  133. package/server-managed/reviewed-dependencies.ts +149 -0
  134. package/server-managed/session.ts +9 -5
  135. package/server-managed/sql.ts +1 -1
  136. package/server-managed/storage-stores.ts +5 -0
  137. package/server-managed/team-app.ts +19 -3
  138. package/server-managed/workspace-shares.ts +4 -1
  139. package/server.ts +11 -0
  140. package/out/chunk-2ABLKCOY.js +0 -46
  141. package/out/chunk-2KBF5TZ7.js +0 -2
  142. package/out/chunk-35VE6CDH.js +0 -2
  143. package/out/chunk-3BZ7JERB.js +0 -1
  144. package/out/chunk-4XCDFGDP.js +0 -10
  145. package/out/chunk-5LSXOLUS.js +0 -1
  146. package/out/chunk-5RCM2J63.js +0 -1
  147. package/out/chunk-7B6HB4D7.js +0 -1
  148. package/out/chunk-AJ4GTATZ.js +0 -3
  149. package/out/chunk-CH4UN6WK.js +0 -225
  150. package/out/chunk-CMQA4DM6.js +0 -1
  151. package/out/chunk-CVVJAXVY.js +0 -2
  152. package/out/chunk-DIJJNTRH.js +0 -10
  153. package/out/chunk-EDKLBQQN.js +0 -17
  154. package/out/chunk-F47DXCEV.js +0 -33
  155. package/out/chunk-FK3DZEKL.js +0 -1
  156. package/out/chunk-G6AJWENO.js +0 -1
  157. package/out/chunk-GJ4IUCNS.js +0 -1
  158. package/out/chunk-GNXOKIGP.js +0 -80
  159. package/out/chunk-GZ4MS3TE.js +0 -13
  160. package/out/chunk-GZWM7UQM.js +0 -2
  161. package/out/chunk-IU3CCINR.js +0 -3
  162. package/out/chunk-K3X5EJBQ.js +0 -2
  163. package/out/chunk-KEX2ATEW.js +0 -2
  164. package/out/chunk-KYCNJVB7.js +0 -1
  165. package/out/chunk-LPCTJYNA.js +0 -7
  166. package/out/chunk-O4RJY5TP.js +0 -2
  167. package/out/chunk-OD4LPN3H.js +0 -1
  168. package/out/chunk-QWDWTBM7.js +0 -1
  169. package/out/chunk-QYWKC557.js +0 -1
  170. package/out/chunk-R75XOKUP.js +0 -1
  171. package/out/chunk-SWTLADW5.js +0 -1
  172. package/out/chunk-T7OKH4VW.js +0 -1
  173. package/out/chunk-TZ5KB2Y5.js +0 -10
  174. package/out/chunk-UNTRGE26.js +0 -2
  175. package/out/chunk-UTR2LVME.js +0 -2
  176. package/out/chunk-UYAXVRVL.js +0 -1
  177. package/out/chunk-V3ME6FI5.js +0 -2
  178. package/out/chunk-V5O7M2YT.js +0 -1
  179. package/out/chunk-VIHGO77Y.js +0 -1
  180. package/out/chunk-W4J3MGLB.js +0 -1
  181. package/out/chunk-WGG7KQUH.js +0 -1
  182. package/out/chunk-XYFXXB2G.js +0 -10
  183. package/out/chunk-ZESLQCTF.js +0 -6
@@ -0,0 +1,76 @@
1
+ // What a `uses:` names, as GitHub reads it (`owner/repo[/path]@ref`, `./path`,
2
+ // `$/path`, `docker://image`), and an image a job runs in.
3
+
4
+ const GITHUB_OWNERS = new Set(['actions', 'github'])
5
+
6
+ const OWNER = /^[\da-z](?:[\da-z]|-(?=[\da-z])){0,38}$/iu
7
+ const NAME = /^(?!\.{1,2}$)[\w.-]{1,100}$/u
8
+ const SHA = /^[\da-f]{40}$/u
9
+ const SHORT_SHA = /^[\da-f]{7,39}$/u
10
+ // A version a comment beside a pin names, as Dependabot, Renovate, pinact
11
+ // and ratchet write one (`# v4.1.1`, `# tag=v4.1.1`, `# ratchet:x@v4`).
12
+ const VERSION_COMMENT = /^#\s*(?:tag\s*[=:]\s*|pin@|ratchet:[^\s@]+@)?(v?\d+(?:\.\d+){0,3}(?:[-+][\w.-]+)?)(?![\w.])/iu
13
+
14
+ // `sha`, `short-sha`, `ref` (a tag or a branch), or `expression`, which GitHub refuses.
15
+ function pinOf(ref) {
16
+ if (/\$\{\{/u.test(ref)) return 'expression'
17
+ if (SHA.test(ref)) return 'sha'
18
+ return SHORT_SHA.test(ref) ? 'short-sha' : 'ref'
19
+ }
20
+
21
+ // An image's name, tag and digest (`[host[:port]/]path[:tag][@digest]`), null
22
+ // for none; null where it isn't one.
23
+ export function readImage(text) {
24
+ if (typeof text !== 'string' || text === '' || /\$\{\{|\s/u.test(text)) return null
25
+ const at = text.indexOf('@')
26
+ const digest = at === -1 ? null : text.slice(at + 1)
27
+ const named = at === -1 ? text : text.slice(0, at)
28
+ const colon = named.lastIndexOf(':')
29
+ const tagged = colon > named.lastIndexOf('/')
30
+ if (digest !== null && !/^sha256:[\da-f]{64}$/u.test(digest)) return null
31
+ return { image: tagged ? named.slice(0, colon) : named, tag: tagged ? named.slice(colon + 1) : null, digest }
32
+ }
33
+
34
+ const commentVersion = comment => typeof comment === 'string' ? VERSION_COMMENT.exec(comment.trim())?.[1] ?? null : null
35
+
36
+ export const isWorkflowPath = path => /^\.github\/workflows\/[^/]+\.ya?ml$/u.test(path)
37
+
38
+ // `{ kind, uses, … }`: `action`, or `workflow` at a job's level (`job`), with
39
+ // its `repo`, `path`, `ref`, `pin` and `comment`'s version; `local`, `self`
40
+ // at the commit running, `job` a reusable workflow's, at the calling
41
+ // workflow's commit; `docker`; or `invalid`, which GitHub won't run.
42
+ export function readUses(text, { job = false, comment = null } = {}) {
43
+ const uses = typeof text === 'string' ? text.trim() : ''
44
+ if (uses.startsWith('./') || uses.startsWith('$/')) {
45
+ return { kind: 'local', uses, path: uses.slice(2).replace(/\/+$/u, ''), ...uses.startsWith('$/') ? { self: true } : {}, ...job ? { job: true } : {} }
46
+ }
47
+ if (uses.startsWith('docker://')) {
48
+ const image = job ? null : readImage(uses.slice('docker://'.length))
49
+ return image ? { kind: 'docker', uses, ...image } : { kind: 'invalid', uses }
50
+ }
51
+ const at = uses.indexOf('@')
52
+ const [owner = '', name = '', ...path] = (at === -1 ? uses : uses.slice(0, at)).split('/')
53
+ const ref = at === -1 ? '' : uses.slice(at + 1)
54
+ if (!OWNER.test(owner) || !NAME.test(name) || ref === '' || path.some(part => !NAME.test(part)) || (job && !isWorkflowPath(path.join('/')))) {
55
+ return { kind: 'invalid', uses }
56
+ }
57
+ return { kind: job ? 'workflow' : 'action', uses, repo: `${owner}/${name}`, path: path.join('/'), ref, pin: pinOf(ref), comment: commentVersion(comment) }
58
+ }
59
+
60
+ // The key a reference is resolved once by, a commit's with its comment's version.
61
+ export const usesKey = ({ repo, path, ref, pin, comment }) => `${repo.toLowerCase()}${path ? `/${path}` : ''}@${ref}${pin === 'sha' && comment ? ` # ${comment}` : ''}`
62
+
63
+ // A reference from its key, a reusable workflow's by its path's.
64
+ export function usesOfKey(key) {
65
+ const m = typeof key === 'string' ? /^([^@\s]+@\S+)(?: # (\S+))?$/u.exec(key) : null
66
+ const job = Boolean(m && /^[^/]+\/[^/]+\/\.github\/workflows\/[^/@]+\.ya?ml@/u.test(m[1]))
67
+ const read = m ? readUses(m[1], { job, comment: m[2] ? `# ${m[2]}` : null }) : null
68
+ return read && (read.kind === 'action' || read.kind === 'workflow') && usesKey(read) === key ? read : null
69
+ }
70
+
71
+ // GitHub's own, the workflow's owner's, or another's.
72
+ export function ownerKind(repo, owner) {
73
+ const of = repo.split('/')[0].toLowerCase()
74
+ if (GITHUB_OWNERS.has(of)) return 'github'
75
+ return typeof owner === 'string' && of === owner.toLowerCase() ? 'own' : 'third-party'
76
+ }
@@ -0,0 +1,110 @@
1
+ // A workflow, or an action's metadata, read for what it runs: its events, its
2
+ // jobs' steps (`uses`, `run`) and images, each on its line from 1.
3
+ import { YamlError, parseYaml } from '@preventive/lockfile/yaml.js'
4
+ import { readImage, readUses } from './uses.js'
5
+
6
+ const isMap = value => value !== null && typeof value === 'object' && !Array.isArray(value)
7
+ const stringOf = value => typeof value === 'string' ? value : null
8
+
9
+ // The line, from 1, `key` of a mapping or an entry of a sequence is on.
10
+ function lineOf(file, container, key, fallback) {
11
+ const at = container !== null && typeof container === 'object' ? file.positions.get(container) : undefined
12
+ const line = at instanceof Map ? at.get(key) : at?.[key]
13
+ return line === undefined ? fallback : line + 1
14
+ }
15
+
16
+ // A step's or a job's `uses`, on its line, with the comment that line ends with.
17
+ function usesOf(file, owner, fallback, job = false) {
18
+ if (typeof owner.uses !== 'string') return null
19
+ const line = lineOf(file, owner, 'uses', fallback)
20
+ return { ...readUses(owner.uses, { job, comment: /(?:^|[ \t])(#.*)$/u.exec(file.lines[line - 1] ?? '')?.[1] ?? null }), line }
21
+ }
22
+
23
+ function triggersOf(on) {
24
+ if (typeof on === 'string') return [on]
25
+ if (Array.isArray(on)) return on.filter(event => typeof event === 'string')
26
+ return isMap(on) ? Object.keys(on) : []
27
+ }
28
+
29
+ // The line a `|` block scalar's text starts on, after its key's; else null.
30
+ const LITERAL_HEADER = /:[ \t]+(?:&\S+[ \t]+)?\|[1-9+-]{0,2}[ \t]*(?:#.*)?$/u
31
+ const literalStart = (file, line) => line !== null && LITERAL_HEADER.test(file.lines[line - 1] ?? '') ? line + 1 : null
32
+
33
+ // A `run:` step's script, and its directory, else `directory` (defaults.run);
34
+ // `production` where its `env`, else the job's or the workflow's (`env`),
35
+ // sets NODE_ENV so, as an install then leaves devDependencies out.
36
+ const envOf = (owner, outer = {}) => ({ ...outer, ...isMap(owner?.env) ? owner.env : {} })
37
+ function runOf(file, step, directory, env) {
38
+ const script = stringOf(step.run)
39
+ if (script === null) return null
40
+ const line = lineOf(file, step, 'run', null)
41
+ return { script, line, start: literalStart(file, line), workingDirectory: stringOf(step['working-directory']) ?? directory,
42
+ production: envOf(step, env).NODE_ENV === 'production' }
43
+ }
44
+
45
+ // A step's or a job's `with`, and the line each input is on.
46
+ function inputsOf(file, owner, line) {
47
+ const inputs = isMap(owner.with) ? owner.with : null
48
+ return { with: inputs, withLines: inputs ? Object.fromEntries(Object.keys(inputs).map(key => [key, lineOf(file, inputs, key, line)])) : {} }
49
+ }
50
+
51
+ function stepsOf(file, steps, directory, env = {}) {
52
+ if (!Array.isArray(steps)) return []
53
+ return steps.flatMap((step, index) => {
54
+ if (!isMap(step)) return []
55
+ const line = lineOf(file, steps, index, null)
56
+ return [{ line, uses: usesOf(file, step, line), run: runOf(file, step, directory, env), ...inputsOf(file, step, line) }]
57
+ })
58
+ }
59
+
60
+ // The directory a workflow's or a job's `defaults.run` names, else `outer`.
61
+ const directoryOf = (owner, outer) => stringOf(isMap(owner?.defaults) && isMap(owner.defaults.run) ? owner.defaults.run['working-directory'] : null) ?? outer
62
+
63
+ // The images a job runs in: its container's and its services'.
64
+ function imagesOf(file, job) {
65
+ const images = []
66
+ const container = isMap(job.container) ? job.container.image : job.container
67
+ if (typeof container === 'string') images.push({ text: container, line: lineOf(file, isMap(job.container) ? job.container : job, isMap(job.container) ? 'image' : 'container', null) })
68
+ if (isMap(job.services)) {
69
+ for (const service of Object.values(job.services)) {
70
+ if (isMap(service) && typeof service.image === 'string') images.push({ text: service.image, line: lineOf(file, service, 'image', null) })
71
+ }
72
+ }
73
+ return images.map(image => ({ ...image, read: readImage(image.text) }))
74
+ }
75
+
76
+ function jobsOf(file, doc) {
77
+ if (!isMap(doc.jobs)) throw new YamlError('a workflow has no jobs')
78
+ const directory = directoryOf(doc, null)
79
+ return Object.values(doc.jobs).filter(isMap).map(job => ({ uses: usesOf(file, job, null, true), secrets: job.secrets ?? null, ...inputsOf(file, job, null),
80
+ steps: stepsOf(file, job.steps, directoryOf(job, directory), envOf(job, envOf(doc))), images: imagesOf(file, job) }))
81
+ }
82
+
83
+ // An action's `runs`: its runtime, a composite one's steps as a job, and a
84
+ // Docker one's image or Dockerfile.
85
+ function runsOf(file, doc) {
86
+ const runs = isMap(doc.runs) ? doc.runs : null
87
+ if (!runs || typeof runs.using !== 'string') throw new YamlError('an action has no runs.using')
88
+ const using = runs.using.toLowerCase()
89
+ const usingLine = lineOf(file, runs, 'using', null)
90
+ const image = using === 'docker' ? stringOf(runs.image) : null
91
+ const imageLine = lineOf(file, runs, 'image', usingLine)
92
+ const docker = image?.startsWith('docker://') ? { text: image.slice('docker://'.length), line: imageLine, read: readImage(image.slice('docker://'.length)) } : null
93
+ const steps = using === 'composite' ? stepsOf(file, runs.steps, null) : []
94
+ return { using, usingLine, dockerfile: image && !docker ? { path: image.replace(/^\.\//u, ''), line: imageLine } : null, jobs: [{ uses: null, steps, images: docker ? [docker] : [] }] }
95
+ }
96
+
97
+ // A workflow (`kind` 'workflow') or an action's metadata ('action') read, or
98
+ // the YamlError of one GitHub can't run.
99
+ export function readWorkflow(kind, text) {
100
+ try {
101
+ const file = { lines: text.split('\n'), positions: new WeakMap() }
102
+ const doc = parseYaml(text, { workflow: true, lines: file.positions })
103
+ if (!isMap(doc)) throw new YamlError(`${kind === 'action' ? 'an action' : 'a workflow'} is not a mapping`)
104
+ if (kind === 'action') return { triggers: [], ...runsOf(file, doc) }
105
+ return { triggers: triggersOf(doc.on), using: null, usingLine: null, dockerfile: null, jobs: jobsOf(file, doc) }
106
+ } catch (error) {
107
+ if (error instanceof YamlError) return error
108
+ throw error
109
+ }
110
+ }
@@ -82,9 +82,9 @@ function githubNamed(url) {
82
82
 
83
83
  // The hosts a script's strings name, each with the line it is first named on,
84
84
  // in the order named, NPM_MAX_HOSTS at most; null for none, or for a file
85
- // that is no script.
86
- export function npmHosts(path, text) {
87
- if (!npmIsScript(path, text) || !(text.includes('://') || text.includes(':\\/\\/'))) return null
85
+ // that is no script, by its name or as node runs it (`script`, npmIsScript).
86
+ export function npmHosts(path, text, script = false) {
87
+ if (!npmIsScript(path, text, script) || !(text.includes('://') || text.includes(':\\/\\/'))) return null
88
88
  const hosts = new Map(), line = lineOf(text)
89
89
  for (const { 1: token, index } of text.matchAll(JS_STRING_OR_COMMENT)) {
90
90
  if (hosts.size >= NPM_MAX_HOSTS) break
@@ -172,9 +172,9 @@ const SAYS_ANY = new RegExp([...EXFILTRATION.values()].map(({ pattern }) => patt
172
172
 
173
173
  // The services a file names, each with the lines it is named on, the first
174
174
  // NPM_MAX_FOUND, in EXFILTRATION's order; null for none, or for prose or a
175
- // source map.
176
- export function npmExfiltration(path, text) {
177
- if (typeof text !== 'string' || NPM_PROSE.test(path) || SOURCE_MAP.test(path) || !SAYS_ANY.test(text)) return null
175
+ // source map by its name that node doesn't run as a script (`script`).
176
+ export function npmExfiltration(path, text, script = false) {
177
+ if (typeof text !== 'string' || (!script && (NPM_PROSE.test(path) || SOURCE_MAP.test(path))) || !SAYS_ANY.test(text)) return null
178
178
  const found = new Map(), line = lineOf(text)
179
179
  for (const [service, { pattern }] of EXFILTRATION) {
180
180
  const lines = new Set()
@@ -45,30 +45,34 @@ export function npmObfuscation(text) {
45
45
 
46
46
  // A code file this large is mostly a bundle, worth a look where a version
47
47
  // adds it: JavaScript or TypeScript, not a declaration file, which holds no
48
- // code that runs.
48
+ // code that runs, or any file node runs as a script (`script`, npm/files.js
49
+ // npmLoadedScripts).
49
50
  export const NPM_LARGE_CODE = 256 * 1024
50
51
  const CODE = /\.(?:[cm]?[jt]s|[jt]sx)$/iu
51
52
  const DECLARATION = /\.d\.[cm]?ts$/iu
52
- export const npmLargeCode = (path, bytes) => typeof bytes === 'number' && bytes >= NPM_LARGE_CODE && CODE.test(path) && !DECLARATION.test(path)
53
+ export const npmLargeCode = (path, bytes, script = false) => typeof bytes === 'number' && bytes >= NPM_LARGE_CODE && (script || (CODE.test(path) && !DECLARATION.test(path)))
53
54
 
54
55
  // A code file that isn't text (npmFileReadability's `binary`: not UTF-8, or
55
- // holding a NUL), by its path and how it reads, a declaration file too: no
56
- // one can read it in review, yet what reads it by its byte order mark can
56
+ // holding a NUL), by its path and how it reads, a declaration file too, or
57
+ // any file node runs as a script (`script`, npm/files.js npmLoadedScripts):
58
+ // no one can read it in review, yet what reads it by its byte order mark can
57
59
  // run it, or type with it, as UTF-16 (a browser's classic script, Windows
58
- // Script Host, TypeScript).
59
- export const npmBinaryCode = (path, readability) => readability?.category === 'binary' && CODE.test(path)
60
+ // Script Host, TypeScript), and node runs it decoded as UTF-8 whatever it
61
+ // holds past its code (`//` and a byte no UTF-8 has).
62
+ export const npmBinaryCode = (path, readability, script = false) => readability?.category === 'binary' && (script || CODE.test(path))
60
63
 
61
64
  // A file with a line over UNREADABLE_LINE characters, by how it reads
62
65
  // (`now`, npmFileReadability's), `path` its path: an inline source map's line
63
66
  // counts for none, and prose and source maps, whose long lines the Long lines
64
- // tag passes over, never are, nor is a file that isn't text. Then one that
65
- // had no line over SHORT_LINES characters (`was`) and now has one over
66
- // UNREADABLE_LINE, by how it reads on each side.
67
+ // tag passes over, never are, but where node runs it as a script (`script`),
68
+ // nor is a file that isn't text. Then one that had no line over SHORT_LINES
69
+ // characters (`was`) and now has one over UNREADABLE_LINE, by how it reads on
70
+ // each side.
67
71
  const SHORT_LINES = 200
68
72
  const UNREADABLE_LINE = 2000
69
- export const npmUnreadable = (path, now) => Boolean(now) && now.category !== 'binary' && !NPM_PROSE.test(path) && !SOURCE_MAP.test(path)
73
+ export const npmUnreadable = (path, now, script = false) => Boolean(now) && now.category !== 'binary' && (script || (!NPM_PROSE.test(path) && !SOURCE_MAP.test(path)))
70
74
  && now.longest > UNREADABLE_LINE
71
- export const npmBecameUnreadable = (path, was, now) => Boolean(was) && was.category !== 'binary' && was.longest <= SHORT_LINES && npmUnreadable(path, now)
75
+ export const npmBecameUnreadable = (path, was, now, script = false) => Boolean(was) && was.category !== 'binary' && was.longest <= SHORT_LINES && npmUnreadable(path, now, script)
72
76
 
73
77
  // How a file reads, as its `category`, the first that holds (READABILITY):
74
78
  // binary (no text), map (a source map), long (code with some lines
@@ -85,8 +89,9 @@ export const npmBecameUnreadable = (path, was, now) => Boolean(was) && was.categ
85
89
  // short ones (strings and comments aside, see MINIFIED_AVERAGE; 0 otherwise),
86
90
  // how long its inline map is (`inlineMap`, 0 for none), and its obfuscator
87
91
  // fingerprint (`obfuscation`, npmObfuscation's; null for none, and for prose
88
- // or a source map).
89
- export function npmFileReadability(path, text) {
92
+ // or a source map). One node runs as a script whatever its name (`script`,
93
+ // script.js npmIsScript) is read as code: no prose nor source map.
94
+ export function npmFileReadability(path, text, script = false) {
90
95
  const encoding = npmTextEncoding(text)
91
96
  if (encoding.kind === 'binary') return { ...encoding, category: 'binary', longest: 0, longLines: 0, average: 0, inlineMap: 0, obfuscation: null }
92
97
  let inlineMap = 0, longChars = 0, longLines = 0, longest = 0
@@ -106,14 +111,15 @@ export function npmFileReadability(path, text) {
106
111
  }
107
112
  // A source map holds the names and sources of the code it maps, and prose
108
113
  // its examples: neither runs.
109
- const obfuscation = NPM_PROSE.test(path) || SOURCE_MAP.test(path) ? null : npmObfuscation(text)
114
+ const map = !script && SOURCE_MAP.test(path), prose = !script && NPM_PROSE.test(path)
115
+ const obfuscation = prose || map ? null : npmObfuscation(text)
110
116
  const read = { ...encoding, longest, longLines, average: 0, inlineMap, obfuscation }
111
- if (SOURCE_MAP.test(path)) return { ...read, category: 'map' }
112
- if (NPM_PROSE.test(path)) return { ...read, category: inlineMap > 0 ? 'inline-map' : encoding.kind }
117
+ if (map) return { ...read, category: 'map' }
118
+ if (prose) return { ...read, category: inlineMap > 0 ? 'inline-map' : encoding.kind }
113
119
  if (longLines === 0) {
114
120
  // None of its lines longer than MINIFIED_AVERAGE, none of its code's can
115
121
  // average more: told without reading its code.
116
- const language = minifiable(path)
122
+ const language = minifiable(path, script)
117
123
  const average = language !== null && longest > MINIFIED_AVERAGE ? minifiedAverage(text, language) : 0
118
124
  return { ...read, average, category: inlineMap > 0 ? 'inline-map' : average > 0 ? 'minified' : encoding.kind }
119
125
  }
@@ -69,7 +69,7 @@ const ENV_WHOLE = String.raw`(?=JSON|Object|URLSearchParams)(?<![\p{ID_Continue}
69
69
  // BusyBox's `ash`, `| powershell`); after a pipe, by its path or env's too (`| /bin/sh`,
70
70
  // `| /usr/bin/env bash`), env's options, their values, and settings before
71
71
  // it on its line (`env -S`, `env -u NAME`, `env MODE=x`).
72
- const SHELLS = String.raw`(?:(?:ba|z|da|k|mk|c|tc|fi|a)?sh|pwsh|powershell)`
72
+ export const SHELLS = String.raw`(?:(?:ba|z|da|k|mk|c|tc|fi|a)?sh|pwsh|powershell)`
73
73
  const SHELL_PATH = String.raw`(?:(?:\/[\w.-]{1,40}){0,8}\/)?(?:env(?:[^\S\n]+(?:-[\w-]{1,20}(?:=\S{0,100}|[^\S\n]+(?!-)[^\s=]{1,100})?|\w{1,40}=\S{0,100})){0,6}[^\S\n]+)?`
74
74
  const SHELL_OR_PLACE = String.raw`curl|wget|\|\s*(?:sudo|${SHELL_PATH}${SHELLS})|powershell|pwsh|rundll32|osascript|mshta|certutil|bitsadmin|\/dev\/(?:tcp|udp)|iex|invoke-expression`
75
75
  + String.raw`|\$profile|profile\.ps1|config\.fish|conf\.d|bashrc|zshrc|zshenv|zlogin|bash_profile|bash_login|zprofile|\.profile|Launch(?:Agents|Daemons)|systemd|systemctl|\.claude|\.vscode|trufflehog|metadata\.google\.internal|["'\x60\/\\]gh(?:\.exe)?["'\x60]`
@@ -118,16 +118,27 @@ const ABSENT_ENDS = String.raw`\s{0,4}(?:(?:\)\s{0,4}){1,4}(?![\s)]{0,8}(?:[.[(]
118
118
  const TOKEN_NAME = String.raw`(?=NPM|GITHUB)(?<![\p{ID_Continue}$])(?<!\b(?:const|let|var)\s{1,4})`
119
119
  + String.raw`(?:(?<!(?:!|\bdelete\s)[\s(]{0,4}(?:[\p{ID_Continue}$]{1,64}\s{0,4}\??\.\s{0,4}){0,4})|(?!${TOKEN_VAR}${ABSENT_ENDS}))`
120
120
  + String.raw`${TOKEN_VAR}(?![\p{ID_Continue}$])(?!${TYPED})(?:(?!${SET})|(?<=[{,]\s{0,64}${TOKEN_VAR}))`
121
+ // eval as a key, not read: a property's name or a member typed, coming
122
+ // first or after another (`{ eval: x }`, WebAuthn's `extensions: { prf: {
123
+ // eval: { first } } }`, `const { eval: run } = vm`, `{ a: 1, eval: 2 }`, `a;
124
+ // eval: T`); not where anything else comes before it, as a ternary's choice
125
+ // does, whole or last of its branch (`c ? eval : f`, `c ? x && eval : f`),
126
+ // nor a worker told to run its string as code (`new Worker(code, { eval:
127
+ // true })`, minified `{eval:!0}`), nor eval destructured from the global
128
+ // object (`const { eval: run } = globalThis`, `({ a, eval: run } = window)`).
129
+ const KEYED = String.raw`(?<=[{,;]${GAP}eval)\s*:(?!${GAP}(?:true|!0)(?![\p{ID_Continue}$]))`
130
+ + String.raw`(?![^{};]{0,200}\}${GAP}=${GAP}(?:${GLOBAL})(?![\p{ID_Continue}$]))`
121
131
  // A script's strings, regular expressions and comments, as script.js
122
132
  // tells them, each taking its own text, and its code in none of them: eval,
123
133
  // not a property TypeScript types as optional (`eval?: T`, JavaScript's `?:`
124
- // none of its own), and Function called, but to find the global object;
134
+ // none of its own) nor a key (KEYED), and Function called, but to find the
135
+ // global object;
125
136
  // Module's _compile, called
126
137
  // (`module._compile(source, filename)`) or hooked (`Module.prototype._compile
127
138
  // = …`), not named in a type (`typeof Module.prototype._compile`); a token's
128
139
  // name, its value read; the environment read out whole; and a PUT request's
129
140
  // call (`axios.put(…)`).
130
- const READS = new RegExp(String.raw`${JS_STRING_OR_COMMENT.source}|(?<eval>${named('eval')}(?![\p{ID_Continue}$])(?!\s*\?:))|(?<function>${named('Function')}${FUNCTION_CALLED}${GLOBAL_FOUND})`
141
+ const READS = new RegExp(String.raw`${JS_STRING_OR_COMMENT.source}|(?<eval>${named('eval')}(?![\p{ID_Continue}$])(?!\s*\?:)(?!${KEYED}))|(?<function>${named('Function')}${FUNCTION_CALLED}${GLOBAL_FOUND})`
131
142
  + String.raw`|(?<compile>\.${GAP}_compile(?:${FUNCTION_CALLED}|${GAP}=(?![=>])))|(?<tokens>${TOKEN_NAME})|(?<env>${ENV_WHOLE})|(?<put>\.${GAP}put${GAP}\()`, 'gmu')
132
143
  // What a string holds that a kind is: a credential file's path, npm's, SSH's
133
144
  // or AWS's (`~/.npmrc`, `.ssh/id_rsa`, `.aws/credentials`, not `x.npmrc` or
@@ -400,12 +411,13 @@ export const RUNS_CODE = new Map([
400
411
 
401
412
  // The kinds a file's code holds, each with the lines it is on, of the first
402
413
  // NPM_MAX_FOUND places it does each, in RUNS_CODE's order; null for none, or
403
- // for a file that is no script. A shell script's, read whole, are its shell
414
+ // for a file that is no script, by its name or as node runs it (`nodeRuns`,
415
+ // npmIsScript's `script`). A shell script's, read whole, are its shell
404
416
  // commands and the places it names; one with a shell's hashbang is one
405
417
  // whatever its extension, and read as JavaScript too where it is that, as
406
418
  // a launcher (`#!/bin/sh` then `exec node "$0"`) is both.
407
- export function npmRunsCode(path, text) {
408
- const script = npmIsScript(path, text)
419
+ export function npmRunsCode(path, text, nodeRuns = false) {
420
+ const script = npmIsScript(path, text, nodeRuns)
409
421
  const shell = typeof text === 'string' && (SHELL_SCRIPT.test(path) || SHELL_HASHBANG.test(text))
410
422
  if (!script && !shell) return null
411
423
  const found = new Map()
@@ -50,7 +50,9 @@ const CSS = {
50
50
  // JavaScript's strings and regular expressions (the first group) and its
51
51
  // comments, for a reader that sets them aside (runs-code.js, hosts.js).
52
52
  export const JS_STRING_OR_COMMENT = JS.stringOrComment
53
- export const minifiable = path => /\.[cm]?js$/iu.test(path) ? JS : /\.css$/iu.test(path) ? CSS : null
53
+ // A stylesheet's by its name, else JavaScript's for a script by its name, or
54
+ // for any other where node runs it as one (`script`).
55
+ export const minifiable = (path, script = false) => /\.css$/iu.test(path) ? CSS : script || /\.[cm]?js$/iu.test(path) ? JS : null
54
56
  // How much of a text is read for how its lines are minified: their average
55
57
  // is told by as much of them, and a text of millions of lines is read no
56
58
  // further.
@@ -81,8 +83,10 @@ export function minifiedAverage(whole, { stringOrComment, droppable, needed }) {
81
83
  // as an ES module (in a `"type": "module"` package).
82
84
  const SCRIPT = /\.[cm]?[jt]sx?$/iu
83
85
  const NODE_HASHBANG = /^\uFEFF?#!.*\bnode\b/u
84
- // Whether a file (its path and text) is a script, as SCRIPT and NODE_HASHBANG tell.
85
- export const npmIsScript = (path, text) => typeof text === 'string' && (SCRIPT.test(path) || NODE_HASHBANG.test(text))
86
+ // Whether a file (its path and text) is a script, as SCRIPT and NODE_HASHBANG
87
+ // tell, or as node runs it whatever its name (`script`, npm/files.js
88
+ // npmLoadedScripts).
89
+ export const npmIsScript = (path, text, script = false) => typeof text === 'string' && (script || SCRIPT.test(path) || NODE_HASHBANG.test(text))
86
90
  // How many places a file is told doing a thing, the first ones, at most
87
91
  // (runs-code.js, hosts.js): a file doing it on millions of lines is told by
88
92
  // its first, and what it holds of them kept to a bound however many there
@@ -22,12 +22,12 @@ const severityOf = mark => mark === 'warn' ? 'high' : 'notice'
22
22
  // obfuscator's fingerprint, a warning's; and its control characters, long
23
23
  // lines and category, by their marks, but for what reads as most files do
24
24
  // (UTF-8, ASCII) and a source map.
25
- function npmStampsOf({ path, bytes, readability, runsCode, exfiltration }) {
25
+ function npmStampsOf({ path, bytes, readability, runsCode, exfiltration, script }) {
26
26
  const category = npmTaggedCategory(readability)
27
27
  const listed = category !== 'ascii' && category !== 'map' && READABILITY.get(category).mark
28
28
  return [...npmRunsCodeFlags(runsCode),
29
29
  ...npmHostTags(exfiltration).map(([tag, services]) => ({ name: tag, severity: npmHostSeverity(tag, services) })),
30
- ...npmLargeCode(path, bytes) ? [{ name: 'Large', severity: 'notice' }] : [],
30
+ ...npmLargeCode(path, bytes, script) ? [{ name: 'Large', severity: 'notice' }] : [],
31
31
  ...readability.obfuscation ? [{ name: 'Obfuscated', severity: 'high' }] : [],
32
32
  ...npmReadabilityLabels(readability).filter(label => label === 'controls' || label === 'long')
33
33
  .map(label => ({ name: READABILITY.get(label).tag, severity: severityOf(npmLabelMark(readability, label)) })),
@@ -38,9 +38,10 @@ function npmStampsOf({ path, bytes, readability, runsCode, exfiltration }) {
38
38
  // isn't, and its size in `bytes`): how it reads (npmFileReadability), what
39
39
  // its code does beyond itself (npmRunsCode), the services it names under the
40
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 }) }
41
+ // `stamps` (npmStampsOf); read as a script where node runs it as one whatever
42
+ // its name (`script`, npm/files.js npmLoadedScripts).
43
+ export function npmFileRisk(path, text, bytes, script = false) {
44
+ const readability = npmFileReadability(path, text, script)
45
+ const exfiltration = npmExfiltration(path, text, script), runsCode = npmRunsCode(path, text, script)
46
+ return { readability, runsCode, exfiltration, hosts: npmHosts(path, text, script), stamps: npmStampsOf({ path, bytes, readability, runsCode, exfiltration, script }) }
46
47
  }
package/risk/index.d.ts CHANGED
@@ -179,4 +179,71 @@ export function npmReadyDownload(name: string, options: { cache: CacheStore; sig
179
179
  // malware.
180
180
  export function npmMalwareAdvisory(advisory: { id?: string | undefined; aliases?: readonly string[] | undefined; cwe?: readonly string[] | undefined; title?: string | undefined }): boolean
181
181
 
182
- export function npmFileRisk(path: string, text: string | null, bytes?: number): unknown
182
+ export function npmFileRisk(path: string, text: string | null, bytes?: number, script?: boolean): unknown
183
+
184
+ // GitHub Actions (actions/), a workflow GitHub can't run thrown as
185
+ // @preventive/lockfile's YamlError. A finding of a workflow's, its `line` from 1; of a reference's own code,
186
+ // with the `file` it is in.
187
+ export interface ActionsFinding extends Finding {
188
+ line?: number | null
189
+ file?: string
190
+ }
191
+
192
+ // What a `uses:` names: an action's or a reusable workflow's, by its `key`,
193
+ // or the repository's own.
194
+ export type ActionUses = { kind: 'action' | 'workflow'; uses: string; repo: string; path: string; ref: string; pin: 'sha' | 'short-sha' | 'ref' | 'expression'; comment: string | null; key?: string; line?: number | null }
195
+ export type Uses = ActionUses | { kind: 'local'; uses: string; path: string; self?: true; job?: true; line?: number | null }
196
+
197
+ export interface ScanOptions {
198
+ // Whose repository it is, as an unpinned `uses:` of its owner's is yellow.
199
+ owner?: string | null
200
+ // Its lockfiles' paths, the packages each locks, as npx runs those, by
201
+ // name and by `name@version`, and those only devDependencies need.
202
+ lockfiles?: { path: string; names: ReadonlySet<string>; dev?: ReadonlySet<string> }[]
203
+ // Its own actions' metadata by their directories, for where they install
204
+ // its packages; else each counts as installing them anywhere.
205
+ actions?: ReadonlyMap<string, string> | null
206
+ }
207
+
208
+ export type WorkflowScan = { uses: Uses[]; findings: ActionsFinding[] } | { error: 'workflow-unreadable'; detail: string }
209
+
210
+ export function scanWorkflow(kind: 'workflow' | 'action', text: string, options?: ScanOptions): WorkflowScan
211
+ // The actions of the repository's own a workflow or an action uses, by
212
+ // their directories, and a Docker action's Dockerfile, from its own.
213
+ export function ownFilesOf(kind: 'workflow' | 'action', text: string): { actions: string[]; dockerfile: string | null }
214
+ // A Dockerfile's findings, each on its line.
215
+ export function dockerfileFindings(text: string): ActionsFinding[]
216
+ // A reference by its key, as a scan names it; null for none.
217
+ export function usesOfKey(key: string): ActionUses | null
218
+
219
+ // GitHub as the asker can read it; null for none.
220
+ export interface ActionsSource {
221
+ // Its name now, where it moved, and the repository it forks.
222
+ repo(name: string): Promise<{ fullName: string; defaultBranch: string; fork?: string | null; archived?: boolean; private?: boolean } | null>
223
+ // Every tag's commit; null where it has too many to list.
224
+ tags(name: string): Promise<{ tag: string; oid: string }[] | null>
225
+ tag(name: string, tag: string): Promise<string | null>
226
+ branch(name: string, branch: string): Promise<string | null>
227
+ // Its branches, and whether those are all.
228
+ branches(name: string): Promise<{ branches: { branch: string; oid: string }[]; complete: boolean }>
229
+ // Whether `sha` is `base` or in its history; null for no such commit. Of two
230
+ // commits, it holds for good.
231
+ contains(name: string, base: string, sha: string): Promise<boolean | null>
232
+ file(name: string, path: string, commit: string): Promise<string | null>
233
+ }
234
+
235
+ // A reference resolved: its repository as GitHub has it now, its ref's
236
+ // commit, what the ref is, the tags on the commit, and the keys of the
237
+ // references its code makes; null for none.
238
+ export interface ResolvedAction extends Pick<ActionUses, 'kind' | 'repo' | 'path' | 'ref'> {
239
+ key: string
240
+ repository: { fullName: string; private: boolean } | null
241
+ commit: string | null
242
+ at: 'sha' | 'tag' | 'branch' | null
243
+ tags: string[]
244
+ findings: ActionsFinding[]
245
+ uses: string[]
246
+ }
247
+
248
+ export function resolveAction(uses: ActionUses, source: ActionsSource, options?: { owner?: string | null }): Promise<ResolvedAction>
249
+ export function keptSource(source: ActionsSource): ActionsSource
package/risk/index.js CHANGED
@@ -1,7 +1,11 @@
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.
1
+ // What calls for a look at a package version, or a repository's GitHub
2
+ // Actions, for a reviewer: risk's heuristics, apart from any view of them.
3
+ // See README.md.
3
4
  export { assessNpm } from './npm/assess.js'
4
5
  export { npmMalwareAdvisory } from './npm/malware.js'
5
6
  export { DEFAULT_MINIMUM_RELEASE_AGE } from './npm/release.js'
6
7
  export { NpmRiskError, npmReadyDownload, npmReadyDownloads } from './npm/registry.js'
7
8
  export { SEVERITIES, npmFileRisk } from './files/stamps.js'
9
+ export { ownFilesOf, scanWorkflow } from './actions/findings.js'
10
+ export { dockerfileFindings, keptSource, resolveAction } from './actions/resolve.js'
11
+ export { usesOfKey } from './actions/uses.js'
@@ -19,7 +19,7 @@ import { npmRiskScore } from './score.js'
19
19
  // Which reading of a version an assessment kept (`risk/npm/assessments`) is:
20
20
  // one more whenever a heuristic changes what it finds, so one kept by an
21
21
  // older reading is read anew.
22
- const RISK_VERSION = 17
22
+ const RISK_VERSION = 18
23
23
 
24
24
  // How long an assessment kept may be as JSON, and what risk reads of either
25
25
  // version's files: a package past it, whose files name thousands of hosts or
@@ -8,10 +8,11 @@
8
8
  // files the open version adds, and `changed`, those it changes, each `{ path,
9
9
  // renamedFrom?, modified? }`, by its path in the open version.
10
10
  import { bundleFileSizes, bundleFilesAsMap } from '../../common/bundle-sources.js'
11
- import { npmVersionFileRisk, npmVersionHosts } from './files.js'
11
+ import { npmLoadedScripts, npmVersionFileRisk, npmVersionHosts } from './files.js'
12
12
  import { npmHostFinding } from './held.js'
13
13
  import { HOST_TAGS, npmHostSeverity, npmHostsTagged } from '../files/hosts.js'
14
14
  import { npmBecameUnreadable, npmBinaryCode, npmLargeCode, npmUnreadable } from '../files/readability.js'
15
+ import { npmIsScript } from '../files/script.js'
15
16
  import { SEVERITIES } from '../files/stamps.js'
16
17
  import { CREDENTIAL_TAGS, npmFoundAdded, npmRunsCodeFlags, npmRunsCodeTagged, npmRunsCodeWithout } from '../files/runs-code.js'
17
18
  import { npmDependencySpecs } from './dependencies.js'
@@ -19,6 +20,21 @@ import { npmFilesDiff } from './diff.js'
19
20
  import { npmInstallScripts } from './install-scripts.js'
20
21
  import { npmInstallScriptsChange, npmProvenanceLost, npmPublisherChange } from './release.js'
21
22
 
23
+ // The files the open version (`other`) runs as scripts that the one compared
24
+ // with (`base`) held, changed or not, but didn't run, by their names
25
+ // (script.js npmIsScript, a `.js` renamed `.txt`) or as code loaded them
26
+ // (npm/files.js npmLoadedScripts), as its code now loads them, each a row as npmFilesDiff's
27
+ // `changed` holds one, its own where it has one: what node runs of them is
28
+ // new, as a changed file's is.
29
+ function npmWokenFiles(base, other, files) {
30
+ const added = new Set(files.onlyOther.map(row => row.path)), rows = new Map(files.changed.map(row => [row.path, row]))
31
+ const before = bundleFilesAsMap(base), loadedBefore = npmLoadedScripts(base)
32
+ // A file that isn't text by its name alone (`payload.js`).
33
+ const ran = path => loadedBefore.has(path) || (before.has(path) && npmIsScript(path, typeof before.get(path) === 'string' ? before.get(path) : ''))
34
+ return [...npmLoadedScripts(other)].filter(path => !added.has(path)).map(path => rows.get(path) ?? { path })
35
+ .filter(row => !ran(row.renamedFrom ?? row.path))
36
+ }
37
+
22
38
  // What the open version does to its files that calls for a look, as red
23
39
  // findings: code that runs beyond itself (runs-code.js), new to a file it
24
40
  // adds or changes, the kinds with their lines (`runs-code`), those reaching
@@ -26,7 +42,8 @@ import { npmInstallScriptsChange, npmProvenanceLost, npmPublisherChange } from '
26
42
  // one of the hosts' tags new to a file it adds or changes (hosts.js
27
43
  // npmExfiltration), a finding a tag a level higher than its own
28
44
  // (npmHostSeverity: `exfiltration` and `remote` red, `ip-lookup` orange); a
29
- // code file of 256 KiB or more it adds (npmLargeCode, `large-code`); a file
45
+ // code file of 256 KiB or more it adds, one node runs as a script whatever
46
+ // its name too (npmLargeCode, `large-code`); a file
30
47
  // it changes that had no line over 200 characters and now has one over 2,000
31
48
  // (npmBecameUnreadable, `now-unreadable`); a file it adds or changes with
32
49
  // an obfuscator's fingerprint where the one compared with had none
@@ -39,13 +56,17 @@ import { npmInstallScriptsChange, npmProvenanceLost, npmPublisherChange } from '
39
56
  // where its source map was rebuilt with it (`rebuilt`, rebuiltWithMap), as a
40
57
  // build minifies a file and writes its map together, and in a `major`
41
58
  // release (npmMajorRelease): `now-unreadable` orange where every such file is
42
- // either, else red.
59
+ // either, else red. A file it held, changed or not, that node now runs as a
60
+ // script (npmWokenFiles) counts as one it changes, large code where it is
61
+ // one, and unreadable now where it wasn't as no script, whatever its lines
62
+ // were.
43
63
  function npmFileFindings(base, other, files, major) {
44
64
  const added = new Set(files.onlyOther)
45
65
  const after = row => npmVersionFileRisk(other, row.path)
46
66
  const before = row => added.has(row) ? null : npmVersionFileRisk(base, row.renamedFrom ?? row.path)
47
67
  const changed = files.changed.filter(row => row.renamedFrom == null || row.modified)
48
- const touched = [...files.onlyOther, ...changed]
68
+ const woken = npmWokenFiles(base, other, files)
69
+ const touched = [...new Set([...files.onlyOther, ...changed, ...woken])]
49
70
  const sizes = bundleFileSizes(other)
50
71
  // The files that hold what they didn't before, each with that.
51
72
  const gained = key => touched.map(row => ({ path: row.path, added: npmFoundAdded(after(row)[key], before(row)?.[key] ?? null) })).filter(row => row.added)
@@ -61,13 +82,16 @@ function npmFileFindings(base, other, files, major) {
61
82
  const rows = exfiltration.map(row => ({ path: row.path, added: npmHostsTagged(row.added, tag) })).filter(row => row.added)
62
83
  return { id: npmHostFinding(tag), severity: rows.length > 0 ? npmHostSeverity(tag, rows[0].added, null) : HOST_TAGS.get(tag), files: rows }
63
84
  }),
64
- { id: 'large-code', severity: 'critical', files: files.onlyOther.filter(row => npmLargeCode(row.path, sizes.get(row.path))).map(row => ({ path: row.path, bytes: sizes.get(row.path) })) },
65
- { id: 'now-unreadable', ...unreadableFinding(major, changed.filter(row => npmBecameUnreadable(row.path, before(row)?.readability, after(row).readability))
85
+ { id: 'large-code', severity: 'critical', files: [...files.onlyOther, ...woken].filter(row => npmLargeCode(row.path, sizes.get(row.path), npmLoadedScripts(other).has(row.path)))
86
+ .map(row => ({ path: row.path, bytes: sizes.get(row.path) })) },
87
+ { id: 'now-unreadable', ...unreadableFinding(major, [...new Set([...changed.filter(row => npmBecameUnreadable(row.path, before(row)?.readability, after(row).readability, npmLoadedScripts(other).has(row.path))),
88
+ ...woken.filter(row => npmUnreadable(row.path, after(row).readability, true) && !npmUnreadable(row.renamedFrom ?? row.path, before(row).readability))])]
66
89
  .map(row => ({ path: row.path, was: before(row).readability.longest, now: after(row).readability.longest,
67
90
  ...rebuiltWithMap(base, other, row.path, row.renamedFrom ?? row.path) ? { rebuilt: true } : {} }))) },
68
91
  { id: 'obfuscated', severity: 'critical', files: touched.filter(row => after(row).readability.obfuscation && !before(row)?.readability.obfuscation)
69
92
  .map(row => ({ path: row.path, obfuscation: after(row).readability.obfuscation })) },
70
- { id: 'binary-code', severity: 'critical', files: touched.filter(row => npmBinaryCode(row.path, after(row).readability) && !npmBinaryCode(row.renamedFrom ?? row.path, before(row)?.readability))
93
+ { id: 'binary-code', severity: 'critical', files: touched.filter(row => npmBinaryCode(row.path, after(row).readability, npmLoadedScripts(other).has(row.path))
94
+ && !npmBinaryCode(row.renamedFrom ?? row.path, before(row)?.readability, npmLoadedScripts(base).has(row.renamedFrom ?? row.path)))
71
95
  .map(row => ({ path: row.path, bytes: sizes.get(row.path) })) },
72
96
  { id: 'kept-runs-code', severity: 'high', files: ofKinds(kept, 'found', npmRunsCodeWithout) },
73
97
  { id: 'kept-credentials', severity: 'high', files: ofKinds(kept, 'found', npmRunsCodeTagged) },
@@ -149,7 +173,7 @@ const unreadableFinding = (major, files) => ({ severity: major || files.every(ro
149
173
  // adds or changes hold all it names anew.
150
174
  function npmNewHosts(base, other, files) {
151
175
  const found = new Map(), known = npmVersionHosts(base)
152
- for (const { path } of [...files.onlyOther, ...files.changed]) {
176
+ for (const path of new Set([...files.onlyOther, ...files.changed, ...npmWokenFiles(base, other, files)].map(row => row.path))) {
153
177
  for (const [host, line] of npmVersionFileRisk(other, path).hosts ?? []) {
154
178
  if (known.has(host) || ALLOWED_HOSTS.has(host)) continue
155
179
  const place = { path, line }
@@ -179,8 +203,8 @@ export function npmBaseFilesTell(other, manifest) {
179
203
  const sizes = bundleFileSizes(other)
180
204
  return [...bundleFilesAsMap(other).keys()].some(path => {
181
205
  const { runsCode, exfiltration, readability, hosts } = npmVersionFileRisk(other, path)
182
- return Boolean(runsCode || exfiltration || readability.obfuscation) || npmUnreadable(path, readability) || npmLargeCode(path, sizes.get(path))
183
- || npmBinaryCode(path, readability)
206
+ return Boolean(runsCode || exfiltration || readability.obfuscation) || npmUnreadable(path, readability, npmLoadedScripts(other).has(path)) || npmLargeCode(path, sizes.get(path), npmLoadedScripts(other).has(path))
207
+ || npmBinaryCode(path, readability, npmLoadedScripts(other).has(path))
184
208
  || [...hosts?.keys() ?? []].some(host => !ALLOWED_HOSTS.has(host))
185
209
  })
186
210
  }