@preventive/triage 1.0.0-alpha.5 → 1.0.0-alpha.50

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 (366) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/README.md +16 -0
  3. package/api/reap.ts +17 -0
  4. package/cli.js +6 -0
  5. package/client/dependency-paths.d.ts +3 -0
  6. package/client/dependency-paths.js +36 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +115 -0
  10. package/common/bundle-commit.d.ts +1 -0
  11. package/common/bundle-commit.js +4 -0
  12. package/common/bundle-compare-diff.d.ts +3 -0
  13. package/common/bundle-compare-diff.js +357 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-compare-versions.js +172 -0
  17. package/common/bundle-comparison.d.ts +8 -0
  18. package/common/bundle-comparison.js +62 -0
  19. package/common/bundle-metadata.d.ts +15 -0
  20. package/common/bundle-metadata.js +226 -0
  21. package/common/bundle-pkg-of.d.ts +1 -0
  22. package/common/bundle-pkg-of.js +90 -0
  23. package/common/bundle-reasons.d.ts +2 -0
  24. package/common/bundle-reasons.js +21 -0
  25. package/common/bundle-source-links.d.ts +6 -0
  26. package/common/bundle-source-links.js +216 -0
  27. package/common/bundle-sourcemap.d.ts +10 -0
  28. package/common/bundle-sourcemap.js +100 -0
  29. package/common/bundle-sources.d.ts +7 -0
  30. package/common/bundle-sources.js +333 -0
  31. package/common/bundle-stats.js +41 -0
  32. package/common/bundle-tabs.js +1 -0
  33. package/common/code-language.js +65 -0
  34. package/common/default-scan-models.ts +31 -0
  35. package/common/dependency-reviews.d.ts +18 -0
  36. package/common/dependency-reviews.js +64 -0
  37. package/common/finding-duplicates.js +58 -0
  38. package/common/finding-filters.js +274 -0
  39. package/common/finding-id.js +47 -0
  40. package/common/github-issue-labels.d.ts +1 -0
  41. package/common/github-issue-labels.js +11 -0
  42. package/common/github-pr.ts +56 -0
  43. package/common/gzip.js +59 -0
  44. package/common/line-diff.d.ts +8 -0
  45. package/common/line-diff.js +57 -0
  46. package/common/linked-workspace-groups.js +63 -0
  47. package/common/managed/agent-tasks.ts +292 -0
  48. package/common/managed/comments.ts +34 -0
  49. package/common/managed/finding-detail.d.ts +2 -0
  50. package/common/managed/finding-detail.js +32 -0
  51. package/common/managed/github-agents.ts +114 -0
  52. package/common/managed/github-logins.ts +49 -0
  53. package/common/managed/npm-packages.d.ts +5 -0
  54. package/common/managed/npm-packages.js +34 -0
  55. package/common/managed/permissions.ts +31 -0
  56. package/common/managed/report-content.ts +72 -0
  57. package/common/managed/report-filter.ts +109 -0
  58. package/common/managed/repository-alias.ts +65 -0
  59. package/common/managed/roles.ts +35 -0
  60. package/common/managed/routes.d.ts +2 -0
  61. package/common/managed/routes.js +347 -0
  62. package/common/managed/scan-models.ts +6 -0
  63. package/common/managed/team-app.d.ts +5 -0
  64. package/common/managed/team-app.js +31 -0
  65. package/common/managed/triage.ts +83 -0
  66. package/common/managed/upload-seal.ts +126 -0
  67. package/common/pretty-print.d.ts +5 -0
  68. package/common/pretty-print.js +24 -0
  69. package/common/revalidation-conflicts.js +48 -0
  70. package/common/revalidation-input-groups.js +56 -0
  71. package/common/save-error-reason.ts +20 -7
  72. package/common/scan-server.ts +13 -0
  73. package/common/security-links.d.ts +5 -0
  74. package/common/security-links.js +33 -0
  75. package/common/server-info.ts +46 -0
  76. package/common/utf8.d.ts +3 -0
  77. package/common/utf8.js +45 -0
  78. package/common/workspace-app.js +39 -0
  79. package/common/workspace-groups.js +176 -0
  80. package/out/activity.js +4 -0
  81. package/out/brotli-fallback.js +3 -3
  82. package/out/chunk-2WDZPXVL.js +3 -0
  83. package/out/chunk-2XUEZHIP.js +28 -0
  84. package/out/chunk-2ZDZWLFY.js +1 -0
  85. package/out/chunk-4HI2MSAV.js +17 -0
  86. package/out/chunk-4XMP7YVH.js +36 -0
  87. package/out/chunk-6LNQTKAV.js +52 -0
  88. package/out/chunk-6S2U2UYG.js +232 -0
  89. package/out/chunk-75YLHBCM.js +3 -0
  90. package/out/chunk-7BKKYVWR.js +7 -0
  91. package/out/chunk-7C7DWL7I.js +1 -0
  92. package/out/chunk-7VAWINQF.js +6 -0
  93. package/out/chunk-C4Z6ZBXV.js +10 -0
  94. package/out/chunk-FC65TPUB.js +1 -0
  95. package/out/chunk-FKBXNUW4.js +1 -0
  96. package/out/chunk-GDPCLSOS.js +2 -0
  97. package/out/chunk-GO7FT4OD.js +1 -0
  98. package/out/chunk-HTBYHSPG.js +1 -0
  99. package/out/chunk-IYPY47MC.js +1 -0
  100. package/out/chunk-IZ7POWI3.js +18 -0
  101. package/out/chunk-JCIIBLKR.js +1 -0
  102. package/out/chunk-NRPC74AM.js +1 -0
  103. package/out/chunk-OOJM4CTU.js +1 -0
  104. package/out/chunk-PBZPYIG7.js +1 -0
  105. package/out/chunk-PNHBZGDD.js +1 -0
  106. package/out/chunk-PWFUOLZK.js +17 -0
  107. package/out/chunk-PXK7EU4V.js +1 -0
  108. package/out/chunk-QB4CBOX2.js +1 -0
  109. package/out/chunk-R3NZFXVR.js +1 -0
  110. package/out/chunk-R5O6FAW4.js +2 -0
  111. package/out/chunk-SXV2T5C4.js +80 -0
  112. package/out/chunk-TQJDYHVE.js +2 -0
  113. package/out/chunk-TUII34K2.js +1 -0
  114. package/out/chunk-UK7RGJT6.js +3 -0
  115. package/out/chunk-VFWMQ3LG.js +2 -0
  116. package/out/chunk-VH4XMZIW.js +2 -0
  117. package/out/chunk-WCPJDAYA.js +10 -0
  118. package/out/chunk-WFYLMF6Z.js +10 -0
  119. package/out/chunk-WYLQWYLA.js +1 -0
  120. package/out/client-managed-import.js +4 -0
  121. package/out/client-managed.js +33 -0
  122. package/out/client-sync.js +11 -17
  123. package/out/compare.js +35 -0
  124. package/out/dialogs.js +55 -0
  125. package/out/github-issue-authorized.html +3 -0
  126. package/out/github-issue-authorized.js +1 -0
  127. package/out/graph.js +17 -4
  128. package/out/index.html +90 -8
  129. package/out/prism.js +14 -10
  130. package/out/scan.js +2 -0
  131. package/out/stasis.svg +20 -0
  132. package/out/terminal.js +366 -39
  133. package/out/view.css +1 -1
  134. package/out/view.js +19 -144
  135. package/package.json +362 -59
  136. package/risk/actions/findings.js +161 -0
  137. package/risk/actions/resolve.js +219 -0
  138. package/risk/actions/scripts/commands.js +182 -0
  139. package/risk/actions/scripts/grade.js +113 -0
  140. package/risk/actions/scripts/packages.js +123 -0
  141. package/risk/actions/scripts/shell.js +169 -0
  142. package/risk/actions/uses.js +76 -0
  143. package/risk/actions/workflow.js +110 -0
  144. package/risk/files/characters.js +196 -0
  145. package/risk/files/hosts.js +205 -0
  146. package/risk/files/readability.js +177 -0
  147. package/risk/files/runs-code.js +615 -0
  148. package/risk/files/script.js +118 -0
  149. package/risk/files/stamps.js +47 -0
  150. package/risk/index.d.ts +254 -0
  151. package/risk/index.js +11 -0
  152. package/risk/npm/assess.js +285 -0
  153. package/risk/npm/combined.js +68 -0
  154. package/risk/npm/compare.js +257 -0
  155. package/risk/npm/dependencies.js +172 -0
  156. package/risk/npm/diff.js +79 -0
  157. package/risk/npm/downloads.js +84 -0
  158. package/risk/npm/files.js +445 -0
  159. package/risk/npm/held.js +63 -0
  160. package/risk/npm/install-scripts.js +34 -0
  161. package/risk/npm/malware.js +36 -0
  162. package/risk/npm/manifest.js +65 -0
  163. package/risk/npm/new-dependency.js +60 -0
  164. package/risk/npm/old-version.js +108 -0
  165. package/risk/npm/registry.js +388 -0
  166. package/risk/npm/release.js +85 -0
  167. package/risk/npm/score.js +59 -0
  168. package/risk/npm/specs.js +124 -0
  169. package/risk/npm/tarball.js +96 -0
  170. package/server-common/database-config.ts +16 -0
  171. package/server-common/initialize.ts +18 -0
  172. package/server-common/npm-advisories.ts +101 -0
  173. package/{server → server-common}/origin.ts +5 -5
  174. package/server-common/reap.ts +48 -0
  175. package/server-common/scan-config.ts +19 -0
  176. package/server-common/standalone.ts +29 -0
  177. package/server-common/storage-crypto.ts +182 -0
  178. package/server-common/storage-log.ts +38 -0
  179. package/server-common/vercel-blob.ts +103 -0
  180. package/server-e2e/app.ts +487 -0
  181. package/{server → server-e2e}/bus-receiver.ts +2 -1
  182. package/{server → server-e2e}/cli.js +9 -4
  183. package/{server → server-e2e}/config.ts +50 -42
  184. package/{server → server-e2e}/db-neon.ts +2 -2
  185. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  186. package/{server → server-e2e}/db-stmt.ts +2 -2
  187. package/{server → server-e2e}/db.ts +7 -7
  188. package/{server → server-e2e}/http.ts +44 -13
  189. package/{server → server-e2e}/hub.ts +40 -9
  190. package/server-e2e/index.ts +17 -0
  191. package/server-e2e/lifecycle.ts +95 -0
  192. package/{server → server-e2e}/neon-driver.ts +2 -2
  193. package/{server → server-e2e}/npm-proxy.ts +12 -145
  194. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  195. package/{server → server-e2e}/objstore/blob.ts +1 -1
  196. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  197. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  198. package/{server → server-e2e}/objstore/init.ts +19 -27
  199. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  200. package/server-e2e/objstore/rest-deny.ts +28 -0
  201. package/server-e2e/objstore/rest-mint.ts +224 -0
  202. package/{server → server-e2e}/objstore/rest.ts +3 -3
  203. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  204. package/{server → server-e2e}/objstore/store.ts +7 -7
  205. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  206. package/{server → server-e2e}/pubsub.ts +12 -9
  207. package/{server → server-e2e}/sign.ts +4 -4
  208. package/{server → server-e2e}/sse-server.ts +60 -33
  209. package/{server → server-e2e}/static.ts +31 -21
  210. package/{server → server-e2e}/sync-handlers.ts +64 -30
  211. package/{server → server-e2e}/ws-server.ts +20 -9
  212. package/server-managed/activity.ts +258 -0
  213. package/server-managed/agent-merges.ts +109 -0
  214. package/server-managed/app-agents.ts +21 -0
  215. package/server-managed/avatar-store.ts +13 -0
  216. package/server-managed/blob-store.ts +17 -0
  217. package/server-managed/brotli.ts +10 -0
  218. package/server-managed/bundle-advisories.ts +120 -0
  219. package/server-managed/bundle-advisory-inventory.ts +77 -0
  220. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  221. package/server-managed/bundle-build-diagnostics.js +23 -0
  222. package/server-managed/bundle-build-leases.ts +41 -0
  223. package/server-managed/bundle-build-worker.js +115 -0
  224. package/server-managed/bundle-build.ts +200 -0
  225. package/server-managed/bundle-cache.ts +211 -0
  226. package/server-managed/bundle-catalog.ts +29 -0
  227. package/server-managed/bundle-commits.ts +140 -0
  228. package/server-managed/bundle-store.ts +24 -0
  229. package/server-managed/bundle-summary-cache.ts +130 -0
  230. package/server-managed/bundle.ts +119 -0
  231. package/server-managed/cache-storage.ts +20 -0
  232. package/server-managed/cli.js +21 -0
  233. package/server-managed/combined.ts +47 -0
  234. package/server-managed/comments.ts +151 -0
  235. package/server-managed/commit-coauthors.ts +106 -0
  236. package/server-managed/commit-lockfiles.ts +73 -0
  237. package/server-managed/comparison.ts +41 -0
  238. package/server-managed/config.ts +164 -0
  239. package/server-managed/content-access.ts +15 -0
  240. package/server-managed/crypto.ts +25 -0
  241. package/server-managed/db-methods.ts +1923 -0
  242. package/server-managed/db-neon.ts +458 -0
  243. package/server-managed/db-schema.ts +238 -0
  244. package/server-managed/db-table-names.ts +22 -0
  245. package/server-managed/db.ts +142 -0
  246. package/server-managed/dependabot-access.ts +60 -0
  247. package/server-managed/dependabot-alerts.ts +185 -0
  248. package/server-managed/dependency-review-comments.ts +100 -0
  249. package/server-managed/dependency-reviews.ts +346 -0
  250. package/server-managed/github-accounts.ts +60 -0
  251. package/server-managed/github-actions.ts +71 -0
  252. package/server-managed/github-app.ts +415 -0
  253. package/server-managed/github-errors.ts +70 -0
  254. package/server-managed/github-graphql.ts +39 -0
  255. package/server-managed/github-issue-links.ts +140 -0
  256. package/server-managed/github-issue-oauth.ts +47 -0
  257. package/server-managed/github-issues.ts +152 -0
  258. package/server-managed/github-link-oauth.ts +69 -0
  259. package/server-managed/github-links.ts +204 -0
  260. package/server-managed/github-metadata.ts +213 -0
  261. package/server-managed/github-oauth.ts +238 -0
  262. package/server-managed/github-pulls.ts +227 -0
  263. package/server-managed/github-requests.ts +39 -0
  264. package/server-managed/github-users.ts +30 -0
  265. package/server-managed/http-response.ts +76 -0
  266. package/server-managed/http.ts +5329 -0
  267. package/server-managed/import-triage.ts +51 -0
  268. package/server-managed/index.ts +192 -0
  269. package/server-managed/kept-briefly.ts +37 -0
  270. package/server-managed/kept.ts +28 -0
  271. package/server-managed/link-reports.ts +162 -0
  272. package/server-managed/locked-packages.ts +129 -0
  273. package/server-managed/lockfile-scope.ts +49 -0
  274. package/server-managed/lru.ts +30 -0
  275. package/server-managed/managed-issues.ts +78 -0
  276. package/server-managed/management-catalog.ts +45 -0
  277. package/server-managed/management.ts +99 -0
  278. package/server-managed/npm-delta-worker.js +23 -0
  279. package/server-managed/npm-delta.ts +194 -0
  280. package/server-managed/npm-insights.ts +449 -0
  281. package/server-managed/npm-kept.ts +215 -0
  282. package/server-managed/npm-loads.ts +264 -0
  283. package/server-managed/npm-manifest.ts +115 -0
  284. package/server-managed/npm-packages.ts +454 -0
  285. package/server-managed/npm-provenance.ts +123 -0
  286. package/server-managed/npm-risk.ts +285 -0
  287. package/server-managed/object-storage-disk.ts +148 -0
  288. package/server-managed/object-storage-vercel.ts +67 -0
  289. package/server-managed/object-storage.ts +75 -0
  290. package/server-managed/package-entry-points.ts +51 -0
  291. package/server-managed/pretty-print.ts +434 -0
  292. package/server-managed/public-workspace.ts +238 -0
  293. package/server-managed/pull-comment-reviews.ts +161 -0
  294. package/server-managed/pull-dependencies.ts +348 -0
  295. package/server-managed/pull-dependency-reviews.ts +328 -0
  296. package/server-managed/pull-reviews.ts +205 -0
  297. package/server-managed/queue.ts +39 -0
  298. package/server-managed/release-commits.ts +84 -0
  299. package/server-managed/rename-diff-worker.js +26 -0
  300. package/server-managed/rename-diff.ts +279 -0
  301. package/server-managed/repo-path.ts +21 -0
  302. package/server-managed/report-migration.ts +35 -0
  303. package/server-managed/report-query.ts +10 -0
  304. package/server-managed/report-response.ts +16 -0
  305. package/server-managed/report-sources.ts +178 -0
  306. package/server-managed/report-store.ts +85 -0
  307. package/server-managed/repository-actions.ts +218 -0
  308. package/server-managed/repository-agents.ts +355 -0
  309. package/server-managed/repository-aliases.ts +91 -0
  310. package/server-managed/repository-browser.ts +294 -0
  311. package/server-managed/repository-changes.ts +284 -0
  312. package/server-managed/repository-code.ts +215 -0
  313. package/server-managed/repository-dependency-reviews.ts +165 -0
  314. package/server-managed/repository-discovery.ts +82 -0
  315. package/server-managed/repository-history.ts +432 -0
  316. package/server-managed/repository-issues.ts +195 -0
  317. package/server-managed/repository-policy.ts +27 -0
  318. package/server-managed/repository-pulls.ts +156 -0
  319. package/server-managed/repository-pulse.ts +460 -0
  320. package/server-managed/repository-risk-assess.ts +105 -0
  321. package/server-managed/repository-risk-audit.ts +79 -0
  322. package/server-managed/repository-risk-read.ts +170 -0
  323. package/server-managed/repository-risk.ts +491 -0
  324. package/server-managed/repository-squashes.ts +168 -0
  325. package/server-managed/request-timing.ts +63 -0
  326. package/server-managed/reviewed-dependencies.ts +150 -0
  327. package/server-managed/revisions.ts +35 -0
  328. package/server-managed/scan-downloads.ts +87 -0
  329. package/server-managed/session.ts +132 -0
  330. package/server-managed/slugs.ts +38 -0
  331. package/server-managed/solidity-entry-points.ts +71 -0
  332. package/server-managed/sql-postgres.ts +34 -0
  333. package/server-managed/sql.ts +88 -0
  334. package/server-managed/static.ts +28 -0
  335. package/server-managed/storage-command.ts +16 -0
  336. package/server-managed/storage-db.ts +183 -0
  337. package/server-managed/storage-encryption.ts +290 -0
  338. package/server-managed/storage-maintenance.ts +259 -0
  339. package/server-managed/storage-payload.ts +121 -0
  340. package/server-managed/storage-stores.ts +91 -0
  341. package/server-managed/storage.ts +82 -0
  342. package/server-managed/team-app.ts +134 -0
  343. package/server-managed/team-catalog.ts +7 -0
  344. package/server-managed/team-feed.ts +180 -0
  345. package/server-managed/team-npm-scopes.ts +106 -0
  346. package/server-managed/team-reports.ts +307 -0
  347. package/server-managed/triage-response.ts +16 -0
  348. package/server-managed/type-only-worker.js +19 -0
  349. package/server-managed/type-only.ts +105 -0
  350. package/server-managed/uploads.ts +106 -0
  351. package/server-managed/upstream-cache.ts +243 -0
  352. package/server-managed/view-as.ts +58 -0
  353. package/server-managed/workspace-shares.ts +169 -0
  354. package/server.ts +70 -0
  355. package/server/index.ts +0 -480
  356. package/server/lifecycle.ts +0 -208
  357. /package/{LICENSE → LICENSE-MIT} +0 -0
  358. /package/{server → server-e2e}/auth.ts +0 -0
  359. /package/{server → server-e2e}/config.example.json +0 -0
  360. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  361. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  362. /package/{server → server-e2e}/objstore/sign.ts +0 -0
  363. /package/{server → server-e2e}/peer.ts +0 -0
  364. /package/{server → server-e2e}/sse-session.ts +0 -0
  365. /package/{server → server-e2e}/util.ts +0 -0
  366. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,219 @@
1
+ // A reference resolved on GitHub (`source`, index.d.ts ActionsSource): its
2
+ // repository, its ref's commit and its own code's findings (README.md).
3
+ import { YamlError } from '@preventive/lockfile/yaml.js'
4
+ import { readWorkflow } from './workflow.js'
5
+ import { workflowFindings, workflowUses } from './findings.js'
6
+ import { imageFinding, scriptFindings } from './scripts/grade.js'
7
+ import { isWorkflowPath, ownerKind, readImage, readUses, usesKey } from './uses.js'
8
+
9
+ // An action's tags as versions: a release's (`v4.2.1`), and a major (`v4`) or
10
+ // minor one (`v4.2`) moved to its line's latest. `[major, minor, patch]`,
11
+ // null for a part a tag hasn't; null for no version.
12
+ export function tagVersion(tag) {
13
+ const m = typeof tag === 'string' ? /^v?(\d{1,9})(?:\.(\d{1,9}))?(?:\.(\d{1,9}))?$/u.exec(tag) : null
14
+ return m && [Number(m[1]), m[2] === undefined ? null : Number(m[2]), m[3] === undefined ? null : Number(m[3])]
15
+ }
16
+
17
+ const isRelease = version => version !== null && version[2] !== null
18
+
19
+ const byVersion = (a, b) => (b[0] - a[0]) || ((b[1] ?? -1) - (a[1] ?? -1)) || ((b[2] ?? -1) - (a[2] ?? -1))
20
+
21
+ // Whether a release is of a major or minor tag's line: `v4`'s `4.x.y`.
22
+ const ofLine = (version, other) => isRelease(other) && other[0] === version[0] && (version[1] === null || other[1] === version[1])
23
+
24
+ // The versions of `tags` (`{ tag, oid }` each), the highest first.
25
+ const versionsOf = tags => tags.flatMap(({ tag, oid }) => { const version = tagVersion(tag); return version ? [{ tag, oid, version }] : [] })
26
+ .toSorted((a, b) => byVersion(a.version, b.version))
27
+
28
+ // The latest release's tag of `tags`, else the highest version's; null where
29
+ // none is one.
30
+ export function latestOf(tags) {
31
+ const versions = versionsOf(tags)
32
+ return (versions.find(({ version }) => isRelease(version)) ?? versions[0])?.tag ?? null
33
+ }
34
+
35
+ // The highest release of each major line, the latest line first: where a
36
+ // commit is most likely in a release's history.
37
+ export function newestOfLines(tags) {
38
+ const seen = new Set()
39
+ return versionsOf(tags).filter(({ version }) => isRelease(version) && !seen.has(version[0]) && seen.add(version[0]))
40
+ }
41
+
42
+ // An instruction's lines, its continuations (`\` at a line's end) joined,
43
+ // and each heredoc's (`<<EOF`) through its delimiter, as BuildKit reads one.
44
+ function instructions(text) {
45
+ const lines = text.split('\n').map(line => line.replace(/\r$/u, ''))
46
+ const found = []
47
+ for (let at = 0; at < lines.length; at++) {
48
+ const start = at
49
+ while (lines[at].endsWith('\\') && at + 1 < lines.length) at++
50
+ for (const { 2: quoted, 3: bare } of lines.slice(start, at + 1).join('\n').matchAll(/(?<!<)<<-?\s*(?:(["'])(.+?)\1|([^\s"'<>|&;()]+))/gu)) {
51
+ do at++
52
+ while (at < lines.length - 1 && lines[at].trim() !== (quoted ?? bare))
53
+ }
54
+ found.push({ start, lines: lines.slice(start, Math.min(at, lines.length - 1) + 1) })
55
+ at = Math.min(at, lines.length - 1)
56
+ }
57
+ return found
58
+ }
59
+
60
+ // A `RUN`'s command, its exec form (`RUN ["sh", "-c", "…"]`) as words.
61
+ function runCommand(text) {
62
+ if (!text.trimStart().startsWith('[')) return text
63
+ try {
64
+ const words = JSON.parse(text)
65
+ return Array.isArray(words) ? words.map(word => /[\s'"]/u.test(String(word)) ? `'${String(word).replaceAll("'", "'\\''")}'` : String(word)).join(' ') : text
66
+ } catch { return text }
67
+ }
68
+
69
+ // A Docker action's Dockerfile: each `FROM` by a tag, a build argument's
70
+ // yellow, and each `RUN` read as a script's (scripts/grade.js).
71
+ export function dockerfileFindings(text) {
72
+ const findings = [], script = [], stages = new Set()
73
+ for (const { start, lines } of instructions(text)) {
74
+ const first = /^\s*(FROM|RUN)\s+(.*)$/iu.exec(lines[0])
75
+ // A RUN's options (`--mount=…`, `--network=…`) aren't its command.
76
+ if (first?.[1].toUpperCase() === 'RUN') { script.push(runCommand(first[2].replace(/^(?:--[a-z-]+(?:=\S*)?\s+)*/iu, '')), ...lines.slice(1)); continue }
77
+ script.push(...lines.map(() => ''))
78
+ if (!first) continue
79
+ const words = lines.join(' ').replaceAll('\\', ' ').trim().split(/\s+/u).slice(1).filter(word => !word.startsWith('--'))
80
+ const [image, as, stage] = words
81
+ const built = image && stages.has(image.toLowerCase())
82
+ if (as?.toLowerCase() === 'as' && stage) stages.add(stage.toLowerCase())
83
+ if (!image || image === 'scratch' || built) continue
84
+ const read = readImage(image)
85
+ if (image.includes('$')) findings.push({ id: 'unpinned-image', severity: 'moderate', image, line: start + 1 })
86
+ else if (!read?.digest) findings.push({ ...imageFinding(image, read), line: start + 1 })
87
+ }
88
+ const run = { script: script.join('\n'), line: 1, start: 1 }
89
+ findings.push(...scriptFindings(run, { locked: () => false, installed: () => false, external: true, triggers: [] }))
90
+ return findings.toSorted((a, b) => a.line - b.line)
91
+ }
92
+
93
+ // How many major lines' releases a commit is looked for in.
94
+ const MAX_LINES = 10
95
+
96
+ async function repositoryOf(uses, source, owner) {
97
+ const info = await source.repo(uses.repo)
98
+ if (!info) return { info: null, findings: [{ id: 'repo-missing', severity: ownerKind(uses.repo, owner) === 'own' ? 'moderate' : 'critical' }] }
99
+ const findings = []
100
+ if (info.fullName.toLowerCase() !== uses.repo.toLowerCase()) findings.push({ id: 'repo-moved', severity: 'high', to: info.fullName })
101
+ if (info.fork) findings.push({ id: 'repo-fork', severity: 'low', parent: info.fork })
102
+ if (info.archived) findings.push({ id: 'repo-archived', severity: 'low' })
103
+ return { info, findings }
104
+ }
105
+
106
+ // Where `sha` is in the history (`default`, `release`, `branch`), `none`,
107
+ // `unknown` where not all could be looked in, or `missing`: each branch and
108
+ // release looked in once, from its commit.
109
+ async function reachable(source, info, sha, tags) {
110
+ const name = info.fullName, releases = newestOfLines(tags ?? []).slice(0, MAX_LINES)
111
+ if (releases.some(({ oid }) => oid === sha)) return 'release'
112
+ const head = await source.branch(name, info.defaultBranch)
113
+ const onDefault = await source.contains(name, head ?? info.defaultBranch, sha)
114
+ if (onDefault === null) return 'missing'
115
+ if (onDefault) return 'default'
116
+ const looked = new Set([head])
117
+ const contains = oid => !looked.has(oid) && looked.add(oid) && source.contains(name, oid, sha)
118
+ for (const { oid } of releases) if (await contains(oid)) return 'release'
119
+ const { branches, complete } = await source.branches(name)
120
+ for (const { oid } of branches) if (oid === sha || await contains(oid)) return 'branch'
121
+ return complete && tags !== null ? 'none' : 'unknown'
122
+ }
123
+
124
+ const tagsOn = (tags, commit) => tags?.filter(({ oid }) => oid === commit).map(({ tag }) => tag) ?? []
125
+
126
+ function tagFindings(ref, commit, tags) {
127
+ const version = tagVersion(ref)
128
+ if (!version) return [{ id: 'not-version', severity: 'moderate' }]
129
+ if (!tags) return []
130
+ const findings = []
131
+ const releases = versionsOf(tags).filter(other => ofLine(version, other.version))
132
+ if (version[2] === null && releases.length > 0 && !releases.some(({ oid }) => oid === commit)) findings.push({ id: 'tag-off-release', severity: 'high', release: releases[0].tag })
133
+ const latest = latestOf(tags)
134
+ if (latest && tagVersion(latest)[0] > version[0]) findings.push({ id: 'outdated', severity: 'low', latest })
135
+ return findings
136
+ }
137
+
138
+ // A ref not a commit, resolved as GitHub resolves it, a tag before a branch.
139
+ async function refOf(uses, info, source, tags) {
140
+ const name = info.fullName
141
+ const [tagged, branched] = await Promise.all([tags ? tags.find(({ tag }) => tag === uses.ref)?.oid ?? null : source.tag(name, uses.ref), source.branch(name, uses.ref)])
142
+ if (!tagged && !branched) return { commit: null, at: null, findings: [{ id: 'ref-missing', severity: 'moderate' }] }
143
+ if (!tagged) return { commit: branched, at: 'branch', findings: [{ id: 'branch-ref', severity: 'high' }] }
144
+ const findings = branched ? [{ id: 'ambiguous-ref', severity: 'high', branch: branched }] : []
145
+ findings.push(...tagFindings(uses.ref, tagged, tags))
146
+ const where = await reachable(source, info, tagged, tags)
147
+ if (where === 'none') findings.push({ id: 'tag-off-branch', severity: 'low' })
148
+ return { commit: tagged, at: 'tag', findings }
149
+ }
150
+
151
+ // Whether a version a comment names is a tag on the commit, or one's line.
152
+ function commentHolds(comment, named) {
153
+ const version = tagVersion(comment)
154
+ return named.some(tag => tag === comment || tag.replace(/^v/u, '') === comment.replace(/^v/u, '') || (version && version[2] === null && ofLine(version, tagVersion(tag))))
155
+ }
156
+
157
+ // A commit, looked for in the history where no tag names it, held to its comment.
158
+ async function pinnedOf(uses, info, source, tags) {
159
+ const commit = uses.ref
160
+ let named = tagsOn(tags, commit)
161
+ if (!tags && uses.comment && await source.tag(info.fullName, uses.comment) === commit) named = [uses.comment]
162
+ const findings = []
163
+ if (named.length === 0) {
164
+ const where = await reachable(source, info, commit, tags)
165
+ if (where === 'missing') findings.push({ id: 'commit-missing', severity: 'high' })
166
+ else if (where === 'none') findings.push({ id: 'imposter-commit', severity: 'critical' })
167
+ else if (where === 'unknown') findings.push({ id: 'unverified-commit', severity: 'high' })
168
+ else if (tags) findings.push({ id: 'untagged-commit', severity: 'moderate', on: where })
169
+ }
170
+ if (uses.comment && !commentHolds(uses.comment, named)) findings.push({ id: 'comment-mismatch', severity: 'high', comment: uses.comment, tags: named })
171
+ return { commit, at: 'sha', findings }
172
+ }
173
+
174
+ // A `$/path` (the repository's own at the commit running) as a reference.
175
+ const selfRef = (uses, info, commit) => readUses(`${info.fullName}/${uses.path}@${commit}`, { job: isWorkflowPath(uses.path) })
176
+
177
+ // What a reference runs at `commit` (action.yml, a reusable workflow, a
178
+ // Dockerfile): its findings, each with its file, and the references it makes.
179
+ async function codeOf(uses, info, commit, source) {
180
+ const dir = uses.path ? `${uses.path}/` : '', name = info.fullName
181
+ const candidates = uses.kind === 'workflow' ? [uses.path] : [`${dir}action.yml`, `${dir}action.yaml`]
182
+ let file = null, text = null
183
+ for (const path of candidates) {
184
+ text = await source.file(name, path, commit)
185
+ if (text !== null) { file = path; break }
186
+ }
187
+ if (text === null) return { findings: [{ id: 'action-missing', severity: 'moderate' }], uses: [] }
188
+ const read = readWorkflow(uses.kind === 'workflow' ? 'workflow' : 'action', text)
189
+ if (read instanceof YamlError) return { findings: [{ id: 'unreadable', severity: 'moderate', file, detail: read.message }], uses: [] }
190
+ const findings = workflowFindings(read, { owner: name.split('/')[0], external: true }).map(finding => ({ ...finding, file }))
191
+ const dockerfile = read.dockerfile && `${dir}${read.dockerfile.path}`
192
+ const docker = dockerfile && !dockerfile.split('/').includes('..') ? await source.file(name, dockerfile, commit) : null
193
+ if (docker !== null) findings.push(...dockerfileFindings(docker).map(finding => ({ ...finding, file: dockerfile })))
194
+ const nested = workflowUses(read).map(each => each.kind === 'local' ? (each.self || each.job) && selfRef(each, info, commit) : each)
195
+ return { findings, uses: nested.filter(each => each?.kind === 'action' || each?.kind === 'workflow').map(usesKey) }
196
+ }
197
+
198
+ // A reference resolved (index.d.ts ResolvedAction); `owner`, whose workflow names it.
199
+ export async function resolveAction(uses, source, { owner = null } = {}) {
200
+ const at = { key: usesKey(uses), kind: uses.kind, repo: uses.repo, path: uses.path, ref: uses.ref }
201
+ const { info, findings } = await repositoryOf(uses, source, owner)
202
+ if (!info) return { ...at, repository: null, commit: null, at: null, tags: [], findings, uses: [] }
203
+ const tags = await source.tags(info.fullName)
204
+ const ref = uses.pin === 'sha' ? await pinnedOf(uses, info, source, tags) : await refOf(uses, info, source, tags)
205
+ const code = ref.commit && !ref.findings.some(({ id }) => id === 'commit-missing') ? await codeOf(uses, info, ref.commit, source) : null
206
+ return { ...at, repository: { fullName: info.fullName, private: Boolean(info.private) }, commit: ref.commit, at: ref.at, tags: tagsOn(tags, ref.commit),
207
+ findings: [...findings, ...ref.findings, ...code?.findings ?? []], uses: code?.uses ?? [] }
208
+ }
209
+
210
+ // `source` with its answers kept, by what was asked, while held: a scan's.
211
+ export function keptSource(source) {
212
+ const kept = new Map()
213
+ const keep = method => (...args) => {
214
+ const key = `${method}\0${JSON.stringify(args)}`
215
+ if (!kept.has(key)) kept.set(key, source[method](...args).catch(error => { kept.delete(key); throw error }))
216
+ return kept.get(key)
217
+ }
218
+ return Object.fromEntries(['repo', 'tags', 'tag', 'branch', 'branches', 'contains', 'file'].map(method => [method, keep(method)]))
219
+ }
@@ -0,0 +1,182 @@
1
+ // What a `run:` script fetches and runs past what a lockfile or a digest pins,
2
+ // command by command (shell.js), each with its line in the script from 0.
3
+ import { NPM_INSTALLS, fetched, npmToolFinds, optionsOf, projectOf, valuesOf } from './packages.js'
4
+ import { pathFrom, scriptCommands, scriptLines } from './shell.js'
5
+ import { SHELLS as SHELL_NAMES } from '../../files/runs-code.js'
6
+ import { readImage } from '../uses.js'
7
+
8
+ // A program's file name, lowercase, past its directory and a Windows extension.
9
+ const programOf = word => (word ?? '').split(/[/\\]/u).at(-1).toLowerCase().replace(/\.(?:cmd|exe|bat|ps1)$|@[\w.-]*$/u, '')
10
+
11
+ const SHELLS = new RegExp(String.raw`^(?:${SHELL_NAMES}|python[\d.]*|node|perl|ruby|php|iex|invoke-expression)$`, 'u')
12
+ const FETCHERS = /^(?:curl|wget|irm|iwr|invoke-restmethod|invoke-webrequest)$/u
13
+ // A script fetched and run on one line: `<(curl …)`, `-c "$(curl …)"`, `iex (irm …)`.
14
+ const FETCHED_AND_RUN = [
15
+ /\b(?:(?:ba|z|da|k)?sh|source|\.)\s+(?:-\S+\s+)*<\(\s*(?:curl|wget)\b/iu,
16
+ /\b(?:(?:ba|z|da|k)?sh|python[\d.]*|node|perl|ruby)\s+(?:-\S+\s+)*-c\s+["']?\$\(\s*(?:curl|wget)\b/iu,
17
+ /\b(?:iex|invoke-expression)\b[^|;]*\b(?:irm|iwr|invoke-restmethod|invoke-webrequest|downloadstring)\b/iu,
18
+ ]
19
+
20
+ // The file curl or wget saves to (`-o f`, `-of`, `--output f`, curl's `-O`,
21
+ // wget's by its URL), or PowerShell's (`-OutFile f`); else null.
22
+ function savedTo(program, argv) {
23
+ for (let at = 1; at < argv.length; at++) {
24
+ const outFile = /^-outfile(?::(.+))?$/iu.exec(argv[at]), word = argv[at]
25
+ if (outFile && !/^(?:curl|wget)$/u.test(program)) return outFile[1] ?? argv[at + 1] ?? null
26
+ const attached = { curl: /^-[a-zA-Z]*o(.+)$/u, wget: /^-[a-zA-Z]*O(.+)$/u }[program]?.exec(word)
27
+ if (attached) return attached[1] === '-' ? null : attached[1]
28
+ if (program === 'curl' && (/^-[a-zA-Z]*o$/u.test(word) || word === '--output')) return argv[at + 1] ?? null
29
+ if (program === 'wget' && (/^-[a-zA-Z]*O$/u.test(word) || word === '--output-document')) return argv[at + 1] === '-' ? null : argv[at + 1] ?? null
30
+ const joined = /^(?:--output|--output-document)=(.+)$/u.exec(word)
31
+ if (joined) return joined[1]
32
+ }
33
+ const url = program === 'wget' || (program === 'curl' && argv.some(word => /^-[a-zA-Z]*O[a-zA-Z]*$/u.test(word) || word === '--remote-name')) ? argv.find(word => /^https?:\/\//u.test(word)) : null
34
+ return url ? URL.parse(url)?.pathname.split('/').at(-1) || null : null
35
+ }
36
+
37
+ // A path from the current directory, its `./` (PowerShell's `.\`, its `\`
38
+ // read as an escape) taken off.
39
+ const local = path => path.replace(/^\.\/?/u, '')
40
+
41
+ // Whether a command runs `file`, as a program or a shell's script.
42
+ function runsFile({ argv }, file) {
43
+ if (local(argv[0]) === local(file) || argv[0] === file) return true
44
+ return (SHELLS.test(programOf(argv[0])) || argv[0] === 'source' || argv[0] === '.') && argv.slice(1).some(word => local(word) === local(file))
45
+ }
46
+
47
+ const pythonPackage = text => {
48
+ if (/^(?:git\+|https?:|file:|\.{0,2}\/)/iu.test(text)) return { name: text, spec: null, exact: /@[\da-f]{40}(?:#|$)/u.test(text), registry: false }
49
+ const m = /^([\w.-]+)(?:\[[^\]]*\])?\s*(.*)$/u.exec(text)
50
+ return m && { name: m[1], spec: m[2] || null, exact: /^===?\s*[\w.!+-]+$/u.test(m[2]), registry: true }
51
+ }
52
+
53
+ // The packages pip installs by name, not a requirements file's or a path.
54
+ const PIP_VALUED = /^(?:-r|--requirement|-c|--constraint|-e|--editable|-i|--index-url|--extra-index-url|-t|--target|--prefix|--root|-f|--find-links)$/u
55
+ const pip = (tool, argv, from) => optionsOf(argv, from, PIP_VALUED).operands.filter(word => !/^\.{1,2}(?:\/|$)/u.test(word))
56
+ .flatMap(text => fetched(tool, pythonPackage(text), { ecosystem: 'pypi' }))
57
+ // A package named `name@version`, as Go's, crates' and Deno's are.
58
+ const atVersion = (text, exact) => {
59
+ const at = text.lastIndexOf('@')
60
+ const spec = at > 0 ? text.slice(at + 1) : null
61
+ return { name: at > 0 ? text.slice(0, at) : text, spec, exact: spec !== null && exact(spec), registry: true }
62
+ }
63
+
64
+ function python(program, argv) {
65
+ if (program === 'pip' || /^pip\d/u.test(program)) return argv[1] === 'install' ? pip(program, argv, 2) : []
66
+ if (/^python[\d.]*$/u.test(program) && argv[1] === '-m' && /^pip\d?$/u.test(argv[2] ?? '') && argv[3] === 'install') return pip(`${program} -m pip`, argv, 4)
67
+ if (program === 'uv' && argv[1] === 'pip' && argv[2] === 'install') return pip('uv pip', argv, 3)
68
+ if (program === 'uvx' || (program === 'uv' && argv[1] === 'tool' && /^(?:run|install)$/u.test(argv[2] ?? ''))) {
69
+ const options = optionsOf(argv, program === 'uvx' ? 1 : 3, /^--(?:from|python|with)$/u), [spec] = [...valuesOf(options, '--from'), ...options.operands]
70
+ return fetched(program === 'uvx' ? 'uvx' : `uv tool ${argv[2]}`, spec && pythonPackage(spec), { ecosystem: 'pypi' })
71
+ }
72
+ if (program === 'pipx' && /^(?:run|install)$/u.test(argv[1] ?? '')) {
73
+ const options = optionsOf(argv, 2, /^--(?:spec|python)$/u), [spec] = [...valuesOf(options, '--spec'), ...options.operands]
74
+ return fetched(`pipx ${argv[1]}`, spec && pythonPackage(spec), { ecosystem: 'pypi' })
75
+ }
76
+ return []
77
+ }
78
+
79
+
80
+ const EXACT_SEMVER = text => /^=?\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/u.test(text)
81
+
82
+ // `go install x@v y@v`, `go run x@v`, a version held to Go's checksum database.
83
+ function go(argv) {
84
+ const { operands } = optionsOf(argv, 2, /^-(?:o|modfile|tags|ldflags|gcflags|C|exec)$/u)
85
+ return (argv[1] === 'run' ? operands.slice(0, 1) : operands).filter(target => target.includes('@'))
86
+ .flatMap(target => fetched(`go ${argv[1]}`, atVersion(target, spec => /^v\d+\.\d+\.\d+/u.test(spec)), { ecosystem: 'go' }))
87
+ }
88
+
89
+ // `cargo install`: each crate by its version, or a repository by its commit;
90
+ // `locked` with `--locked`.
91
+ function cargo(argv) {
92
+ if (argv.includes('--path')) return []
93
+ const options = optionsOf(argv, 2, /^--(?:version|git|branch|rev|tag|root|features|registry|index|bin|example|target|profile|jobs)$|^-[jF]$/u)
94
+ const [git = null] = valuesOf(options, '--git'), [version = null] = valuesOf(options, '--version'), [ref = null] = valuesOf(options, '--rev', '--tag', '--branch')
95
+ const specs = git ? [{ name: git, spec: ref, exact: /^[\da-f]{40}$/u.test(valuesOf(options, '--rev')[0] ?? ''), registry: false }] : options.operands.map(crate => atVersion(crate, EXACT_SEMVER))
96
+ return specs.flatMap(spec => fetched(`cargo ${argv[1]}`, { ...spec, spec: spec.spec ?? version, exact: spec.exact || (!git && EXACT_SEMVER(version ?? '')), locked: argv.includes('--locked') }, { ecosystem: 'crates' }))
97
+ }
98
+
99
+ // `gem install x [-v 1.2.3]`.
100
+ function gem(argv) {
101
+ const options = optionsOf(argv, 2, /^(?:-v|--version|-i|--install-dir|-s|--source|-n|--bindir)$/u), [version = null] = valuesOf(options, '-v', '--version')
102
+ return options.operands.flatMap(name => fetched('gem install', { name, spec: version, exact: /^=?\s*\d+(?:\.\d+)+$/u.test(version ?? ''), registry: true }, { ecosystem: 'rubygems' }))
103
+ }
104
+
105
+ // `deno run|install|x` of a URL or a registry's package.
106
+ function deno(argv) {
107
+ const target = argv.slice(2).find(word => /^(?:https?:\/\/|npm:|jsr:)/u.test(word))
108
+ if (!target) return []
109
+ return fetched(`deno ${argv[1]}`, /^(?:npm|jsr):/u.test(target) ? atVersion(target.replace(/^(?:npm|jsr):/u, ''), EXACT_SEMVER)
110
+ : { name: target, spec: null, exact: /@v?\d+\.\d+\.\d+/u.test(target), registry: false }, { ecosystem: 'deno' })
111
+ }
112
+
113
+ function others(program, argv) {
114
+ if (program === 'go' && /^(?:install|run)$/u.test(argv[1] ?? '')) return go(argv)
115
+ if (program === 'cargo' && /^(?:install|binstall)$/u.test(argv[1] ?? '')) return cargo(argv)
116
+ if (program === 'gem' && argv[1] === 'install') return gem(argv)
117
+ return program === 'deno' && /^(?:run|install|x|compile)$/u.test(argv[1] ?? '') ? deno(argv) : []
118
+ }
119
+
120
+ // What a command fetches or runs unpinned, but a script fetched and run.
121
+ function commandFinds(command) {
122
+ const { argv } = command
123
+ const program = programOf(argv[0])
124
+ if (/^(?:docker|podman)$/u.test(program) && /^(?:run|pull|create)$/u.test(argv[1] ?? '')) {
125
+ const image = argv.slice(2).find((word, at, words) => !word.startsWith('-') && !/^(?:-[evpwulhm]|--(?:env|volume|publish|workdir|user|label|hostname|memory|name|network|entrypoint|platform|mount|env-file|cpus|add-host|cap-add|device|dns|gpus|ipc|pid|restart|runtime|pull|security-opt|shm-size|tmpfs|ulimit|volumes-from|log-driver))$/u.test(words[at - 1] ?? ''))
126
+ return image ? [{ kind: 'image', text: image, read: readImage(image) }] : []
127
+ }
128
+ if (program === 'git' && argv[1] === 'clone') return [{ kind: 'clone', url: argv.slice(2).find(word => /^(?:https?:|git@|git:|ssh:)/u.test(word)) ?? null }]
129
+ return [...npmToolFinds(program, argv), ...python(program, argv), ...others(program, argv)]
130
+ }
131
+
132
+ const INSTALLS = { npm: new RegExp(`^(?:ci|clean-install|ic|install-clean|${NPM_INSTALLS})$`, 'u'), pnpm: /^(?:i|install|add)$/u, bun: /^(?:i|install|add)$/u }
133
+ // An install's options that leave the project's node_modules as they are.
134
+ const NOT_LOCAL = /^(?:-g|--location=global|--(?:global|package-lock-only|lockfile-only|dry-run)(?:=true)?)$/u
135
+
136
+ // A script's installs of a project's packages: each one's `order`, the
137
+ // directory it installs into, by its `cd`s and the one it names (`into`, a
138
+ // path from the step's, '' for it), and whether it leaves devDependencies out
139
+ // (`prod`), by an option or NODE_ENV production (the step's `production`).
140
+ export function installsOf(script, production = false) {
141
+ const found = []
142
+ let exported = production
143
+ for (const { argv, dir, env, order } of scriptCommands(script)) {
144
+ const program = programOf(argv[0])
145
+ if (program === 'export') for (const word of argv) if (word.startsWith('NODE_ENV=')) exported = word === 'NODE_ENV=production'
146
+ const options = optionsOf(argv, 1), [command] = options.operands
147
+ if (argv.some(word => NOT_LOCAL.test(word)) || !(program === 'yarn' ? command === undefined || command === 'install' : INSTALLS[program]?.test(command ?? ''))) continue
148
+ // npm installs what `--include` names whatever leaves it out.
149
+ const prod = !valuesOf(options, '--include').includes('dev') && (valuesOf(options, '--omit').includes('dev') || /^prod(?:uction)?$/u.test(valuesOf(options, '--only')[0] ?? '')
150
+ || argv.some(word => /^--prod(?:uction)?$/u.test(word)) || (env.NODE_ENV ?? (exported ? 'production' : '')) === 'production')
151
+ const project = projectOf(argv)
152
+ found.push({ order, into: project === null ? dir : pathFrom(dir, project), prod })
153
+ }
154
+ return found
155
+ }
156
+
157
+ // A script's line, as a finding names what it runs, cut short.
158
+ const excerpt = text => { const trimmed = text.trim().replaceAll(/\s+/gu, ' '); return trimmed.length > 120 ? `${trimmed.slice(0, 119)}…` : trimmed }
159
+
160
+ // Everything a script fetches and runs past a pin, in its order.
161
+ export function scriptFinds(script) {
162
+ const found = []
163
+ const lines = new Map(scriptLines(script).map(({ text, line }) => [line, text]))
164
+ for (const [line, text] of lines) {
165
+ if (FETCHED_AND_RUN.some(pattern => pattern.test(text))) found.push({ kind: 'remote-script', line, command: excerpt(text) })
166
+ }
167
+ const saved = new Map()
168
+ for (const command of scriptCommands(script)) {
169
+ const program = programOf(command.argv[0])
170
+ if (FETCHERS.test(program) && command.pipedTo.some(each => SHELLS.test(programOf(each.argv[0])))) found.push({ kind: 'remote-script', line: command.line, command: excerpt(lines.get(command.line) ?? '') })
171
+ const file = FETCHERS.test(program) ? savedTo(program, command.argv) ?? command.out : null
172
+ if (file) saved.set(file, command.line)
173
+ for (const [name, at] of saved) {
174
+ if (FETCHERS.test(program) || !runsFile(command, name)) continue
175
+ found.push({ kind: 'remote-script', line: command.line, command: excerpt(lines.get(at) ?? '') })
176
+ saved.delete(name)
177
+ }
178
+ for (const each of commandFinds(command)) found.push({ ...each, line: command.line, order: command.order, dir: command.dir })
179
+ }
180
+ const told = new Set()
181
+ return found.filter(({ order: _order, ...each }) => { const key = JSON.stringify(each); return !told.has(key) && told.add(key) }).toSorted((a, b) => a.line - b.line)
182
+ }
@@ -0,0 +1,113 @@
1
+ // What a `run:` script fetches and runs (commands.js), graded (README.md).
2
+ import { HOST_TAGS, npmExfiltration, npmHostTags } from '../../files/hosts.js'
3
+ import { lineOf } from '../../files/script.js'
4
+ import { installsOf, scriptFinds } from './commands.js'
5
+
6
+ // The events a workflow runs on with its secrets for anyone's pull request.
7
+ const PRIVILEGED_TRIGGERS = new Set(['pull_request_target', 'workflow_run', 'issue_comment', 'issues', 'discussion', 'discussion_comment',
8
+ 'pull_request_review', 'pull_request_review_comment', 'fork', 'watch'])
9
+
10
+ // The expressions anyone can write: a title, a body, a branch's name, a
11
+ // commit's message, a fork's description.
12
+ const INDEX = String.raw`(?:\s*\[[^\]]*\]|\.\*)`
13
+ const AUTHOR = String.raw`(?:author|committer)\.(?:email|name)`
14
+ const UNTRUSTED = new RegExp(String.raw`\bgithub\s*\.\s*(?:head_ref|event\s*\.\s*(?:`
15
+ + [String.raw`issue\.(?:title|body)`,
16
+ String.raw`pull_request\.(?:title|body|head\.(?:ref|label|repo\.(?:default_branch|description|homepage)))`,
17
+ String.raw`(?:comment|review|review_comment)\.body`,
18
+ String.raw`pages${INDEX}\.page_name`,
19
+ String.raw`commits${INDEX}\.(?:message|${AUTHOR})`,
20
+ String.raw`head_commit\.(?:message|${AUTHOR})`,
21
+ String.raw`workflow_run\.(?:head_branch|display_title|head_commit\.(?:message|${AUTHOR})|head_repository\.(?:description|homepage|default_branch)|pull_requests${INDEX}\.head\.ref)`,
22
+ String.raw`discussion\.(?:title|body)`].join('|')
23
+ + String.raw`))(?![\w-])`, 'gu')
24
+
25
+ // An expression with its index syntax's properties (`github['event']`) as
26
+ // dereferences, as GitHub reads either.
27
+ export const dotted = text => text.replaceAll(/\[\s*'([\w-]+)'\s*\]/gu, '.$1')
28
+
29
+ // Each expression anyone can write in a script's `text`, a finding with
30
+ // the `index` its `${{` is at: red where the workflow runs with its secrets
31
+ // for anyone (`triggers`), else orange.
32
+ export function scriptInjections(text, triggers) {
33
+ if (typeof text !== 'string' || !text.includes('${{')) return []
34
+ const severity = triggers.some(event => PRIVILEGED_TRIGGERS.has(event)) ? 'critical' : 'high'
35
+ return [...text.matchAll(/\$\{\{([\s\S]*?)\}\}/gu)].flatMap(({ 1: inner, index }) =>
36
+ [...dotted(inner).matchAll(UNTRUSTED)].map(({ 0: expression }) => ({ id: 'script-injection', severity, expression: expression.replaceAll(/\s+/gu, ''), index })))
37
+ }
38
+
39
+ // A ref or a repository naming a pull request's own code, as
40
+ // `actions/checkout` checks it out: its head's commit or branch (a
41
+ // `workflow_run`'s pull requests' too), or the fork's repository, whose
42
+ // default branch it is then.
43
+ const PULL_HEAD = new RegExp(String.raw`\bgithub\.(?:head_ref|event\.(?:number|pull_request\.(?:number|merge_commit_sha|head\.(?:sha|ref|repo\.(?:full_name|owner\.login)))|workflow_run\.(?:head_sha|head_branch|head_commit\.id|pull_requests\[[^\]]*\]\.head\.(?:sha|ref)|head_repository\.(?:full_name|owner\.login))))\b`
44
+ .replaceAll('\\.', String.raw`\s*\.\s*`) + String.raw`|refs\/pull\/`, 'u')
45
+ export const namesPullHead = text => typeof text === 'string' && PULL_HEAD.test(dotted(text))
46
+
47
+ // Programs whose package is named otherwise: `npx tsc` runs typescript's.
48
+ const KNOWN_BINS = new Map([['tsc', 'typescript'], ['tsserver', 'typescript'], ['ng', '@angular/cli'], ['nest', '@nestjs/cli'],
49
+ ['changeset', '@changesets/cli'], ['biome', '@biomejs/biome'], ['svelte-kit', '@sveltejs/kit'], ['api-extractor', '@microsoft/api-extractor'],
50
+ ['commitlint', '@commitlint/cli'], ['attw', '@arethetypeswrong/cli'], ['ncu', 'npm-check-updates'], ['vue-cli-service', '@vue/cli-service'],
51
+ ['firebase', 'firebase-tools'], ['netlify', 'netlify-cli'], ['cdk', 'aws-cdk'], ['sls', 'serverless'], ['playwright', '@playwright/test'],
52
+ ['eas', 'eas-cli'], ['expo', 'expo'], ['vsce', '@vscode/vsce'], ['wdio', '@wdio/cli'], ['lhci', '@lhci/cli'], ['tsx', 'tsx'], ['webpack', 'webpack-cli']])
53
+ const PACKAGING = new Set(['pip', 'setuptools', 'wheel'])
54
+
55
+ // Whether `locked` (a Set) has what npx runs by `name`, or the package giving it.
56
+ export const lockedName = (locked, name) => locked.has(name) || (KNOWN_BINS.has(name) && locked.has(KNOWN_BINS.get(name)))
57
+
58
+ // How severe fetching a package is; null for none.
59
+ function packageSeverity(found, { locked, installed, external }) {
60
+ const runsOwn = found.ecosystem === 'npm' && found.localFirst && found.registry && (found.spec === null || found.exact)
61
+ if (runsOwn && (external ? installed : locked && installed)) return null
62
+ if (found.ecosystem === 'go' && found.exact) return null
63
+ if (found.ecosystem === 'crates' && found.exact && found.locked) return null
64
+ if (found.ecosystem === 'pypi' && PACKAGING.has(found.name.toLowerCase())) return 'notice'
65
+ // A version a variable names may be one, set elsewhere in the workflow.
66
+ return found.exact || found.spec?.includes('$') ? 'moderate' : 'high'
67
+ }
68
+
69
+ // The findings of one find of a script, `locked` what a lockfile says of it.
70
+ function graded(found, context, locked) {
71
+ if (found.kind === 'package') {
72
+ const { tool, name, spec, localFirst } = found, installed = context.installed
73
+ const severity = packageSeverity(found, { locked, installed, external: context.external })
74
+ return severity ? [{ id: 'fetched-package', severity, tool, name, spec, ...localFirst ? { localFirst, locked, installed } : {} }] : []
75
+ }
76
+ if (found.kind === 'unlocked-install') return [{ id: 'unlocked-install', severity: /^(?:yarn|bun)\b/u.test(found.tool) ? 'low' : 'moderate', tool: found.tool }]
77
+ if (found.kind === 'remote-script') return [{ id: 'remote-script', severity: 'high', command: found.command }]
78
+ if (found.kind === 'image') return found.read?.digest ? [] : [imageFinding(found.text, found.read)]
79
+ return found.kind === 'clone' ? [{ id: 'clone', severity: 'low', url: found.url }] : []
80
+ }
81
+
82
+ // An image run by a tag: orange with none or `latest`, else yellow.
83
+ export const imageFinding = (text, read) => ({ id: 'unpinned-image', severity: !read || read.tag === null || read.tag === 'latest' ? 'high' : 'moderate', image: text })
84
+
85
+ // The file's line a script's line is on: a `|` block scalar's own, else `run:`'s.
86
+ const fileLine = (run, line) => run.start === null ? run.line : run.start + line
87
+
88
+ // Whether `dir` is `directory` or above it, as node finds a package there;
89
+ // null for anywhere.
90
+ export const within = (dir, directory) => dir === null || dir === '' || directory === dir || directory.startsWith(`${dir}/`)
91
+
92
+ // Every finding of a `run` script (workflow.js runOf); `context`'s
93
+ // `locked(name, dir)` whether a lockfile there locks it (`dev` where
94
+ // devDependencies alone need it), `installed(dev, dir)` whether an install
95
+ // ran before that installs such a package there, `dir` a path from the
96
+ // step's directory, as a `cd` moves to.
97
+ export function scriptFindings(run, context) {
98
+ const findings = []
99
+ // An install earlier in the script counts as one in a step before.
100
+ const installs = installsOf(run.script, run.production)
101
+ for (const found of scriptFinds(run.script)) {
102
+ const dir = found.dir ?? ''
103
+ const locked = found.kind === 'package' && found.ecosystem === 'npm' && found.localFirst && context.locked(found.exact ? `${found.name}@${found.spec.replace(/^[v=]/u, '')}` : found.name, dir)
104
+ const before = context.installed(locked === 'dev', dir) || installs.some(({ order, into, prod }) => order < found.order && within(into, dir) && !(prod && locked === 'dev'))
105
+ for (const finding of graded(found, { ...context, installed: before }, Boolean(locked))) findings.push({ ...finding, line: fileLine(run, found.line) })
106
+ }
107
+ for (const [tag, services] of npmHostTags(npmExfiltration('run.sh', run.script, true))) {
108
+ findings.push({ id: 'hosts', severity: HOST_TAGS.get(tag), tag, services: [...services.keys()], line: fileLine(run, Math.min(...[...services.values()].flat()) - 1) })
109
+ }
110
+ const line = lineOf(run.script)
111
+ for (const { index, ...finding } of scriptInjections(run.script, context.triggers)) findings.push({ ...finding, line: fileLine(run, line(index) - 1) })
112
+ return findings
113
+ }
@@ -0,0 +1,123 @@
1
+ // What a command of npm's tools fetches and runs past what a lockfile locks
2
+ // (commands.js), npx's and its kin's the project's own where installed
3
+ // (`localFirst`), and an install that resolves anew (`unlocked`).
4
+ import { isExactVersion } from '@preventive/upstream/semver.js'
5
+
6
+ // A package spec's name and spec, `exact` for a version, `registry: false` for
7
+ // a URL, a path, git or a GitHub shorthand.
8
+ export function npmSpec(text) {
9
+ if (/^(?:\.{0,2}\/|~|[a-z][\w+.-]*:|[\w.-]+\/[\w.-]+(?:#.*)?$)/iu.test(text) && !/^@[\w.-]+\//u.test(text)) {
10
+ return { name: text, spec: null, exact: false, registry: false }
11
+ }
12
+ const at = text.indexOf('@', text.startsWith('@') ? 1 : 0)
13
+ const name = at === -1 ? text : text.slice(0, at)
14
+ const spec = at === -1 ? null : text.slice(at + 1)
15
+ if (!/^(?:@[\w.-]+\/)?[\w.-]+$/u.test(name)) return null
16
+ return { name, spec, exact: spec !== null && isExactVersion(spec.replace(/^[v=]/u, '')), registry: true }
17
+ }
18
+
19
+ // A package a tool fetches, as a script's find; none for none.
20
+ export const fetched = (tool, spec, { ecosystem = 'npm', localFirst = false, ...more } = {}) => spec ? [{ kind: 'package', tool, ecosystem, localFirst, ...more, ...spec }] : []
21
+
22
+ // What `npm create x` runs: `create-x`, `@s/create` for `@s`, `@s/create-x`.
23
+ function createPackage(text) {
24
+ const scoped = /^(@[\w.-]+)(?:@(.+))?$/u.exec(text)
25
+ if (scoped) return npmSpec(`${scoped[1]}/create${scoped[2] ? `@${scoped[2]}` : ''}`)
26
+ const spec = npmSpec(text)
27
+ if (!spec?.registry) return spec
28
+ const [scope, name] = spec.name.startsWith('@') ? spec.name.split('/') : [null, spec.name]
29
+ const named = scope ? (name ? `${scope}/create-${name}` : `${scope}/create`) : `create-${name}`
30
+ return { ...spec, name: named }
31
+ }
32
+
33
+ // Options of npm's tools that take a value.
34
+ const VALUED = /^(?:-[pcwC]|--(?:package|call|cache|userconfig|registry|workspace|prefix|filter|dir|global-dir|store-dir|loglevel|cwd|spec|location|include|omit|only))$/u
35
+ const GLOBAL = /^(?:-g|--global|--location=global)$/u
36
+
37
+ // A command's words past `from`: those not options (`operands`), all past
38
+ // `--` too, and each option's values (`values`), of `--x=v`, and `--x v` for
39
+ // one `valued` names.
40
+ export function optionsOf(argv, from, valued = VALUED) {
41
+ const operands = [], values = new Map()
42
+ for (let at = from; at < argv.length; at++) {
43
+ const [, name, joined] = /^(--[\w-]+)=(.*)$/u.exec(argv[at]) ?? [], word = argv[at]
44
+ if (word === '--') return { operands: [...operands, ...argv.slice(at + 1)], values }
45
+ if (name || valued.test(word)) values.set(name ?? word, [...values.get(name ?? word) ?? [], joined ?? argv[++at] ?? ''])
46
+ else if (!word.startsWith('-')) operands.push(word)
47
+ }
48
+ return { operands, values }
49
+ }
50
+ const positional = (argv, from) => optionsOf(argv, from).operands
51
+ // The values of `names`' options, in their order.
52
+ export const valuesOf = ({ values }, ...names) => names.flatMap(name => values.get(name) ?? [])
53
+
54
+ // What an exec (npx, `npm exec`, bunx, dlx) fetches: its `--package`s, else
55
+ // its command's; none with `--no`, `--offline`, or a command line (`-c`).
56
+ function execPackages(tool, argv, from, localFirst) {
57
+ if (argv.some(word => /^(?:--no|--no-install|--offline)$/u.test(word))) return []
58
+ const named = valuesOf(optionsOf(argv, 1), '-p', '--package').filter(Boolean)
59
+ if (named.length === 0 && argv.some(word => word === '-c' || word === '--call')) return []
60
+ return (named.length > 0 ? named : positional(argv, from).slice(0, 1)).flatMap(text => fetched(tool, npmSpec(text), { localFirst }))
61
+ }
62
+
63
+ // The directory an install names for its project (`--prefix`, `-C`, `--dir`,
64
+ // `--cwd`); null for the one it runs in, `.` too.
65
+ export function projectOf(argv) {
66
+ const [dir = null] = valuesOf(optionsOf(argv, 1), '-C', '--prefix', '--dir', '--cwd')
67
+ return dir === null || /^\.\/?$/u.test(dir) ? null : dir
68
+ }
69
+
70
+ const installs = (tool, words) => words.flatMap(text => fetched(tool, npmSpec(text)))
71
+ // npm's install command and its aliases (commands.js installsOf).
72
+ export const NPM_INSTALLS = 'i|in|ins|inst|insta|instal|install|isnt|isnta|isntal|isntall|add|it|install-test'
73
+ const created = (tool, text) => text ? fetched(tool, createPackage(text)) : []
74
+ const unlocked = tool => [{ kind: 'unlocked-install', tool }]
75
+ const isInstalled = word => /^(?:\.{1,2}(?:\/|$)|file:|link:|workspace:)/u.test(word)
76
+
77
+ function npm(argv) {
78
+ const [command = '', ...rest] = positional(argv, 1)
79
+ if (command === 'exec' || command === 'x') return execPackages(`npm ${command}`, argv, argv.indexOf(command) + 1, true)
80
+ if (/^(?:create|init|innit)$/u.test(command)) return created(`npm ${command}`, rest[0])
81
+ if (new RegExp(`^(?:${NPM_INSTALLS})$`, 'u').test(command)) {
82
+ const named = rest.filter(word => !isInstalled(word))
83
+ if (argv.some(word => GLOBAL.test(word))) return installs(`npm ${command} -g`, named)
84
+ return named.length > 0 ? installs(`npm ${command}`, named) : unlocked(`npm ${command}`)
85
+ }
86
+ return /^(?:update|up|upgrade|udpate)$/u.test(command) ? unlocked(`npm ${command}`) : []
87
+ }
88
+
89
+ // pnpm's and bun's, by the word each runs a package with (`exec`), what an
90
+ // install resolving anew is told by (`anew`, null for none), and its updates.
91
+ const pnpmLike = (program, exec, localFirst, anew, updates) => argv => {
92
+ const [command = '', ...rest] = positional(argv, 1)
93
+ if (command === exec) return execPackages(`${program} ${exec}`, argv, argv.indexOf(exec) + 1, localFirst)
94
+ if (command === 'create') return created(`${program} create`, rest[0])
95
+ const global = argv.some(word => GLOBAL.test(word)), installing = command === 'install' || command === 'i'
96
+ if (command === 'add' || (installing && rest.length > 0)) return installs(`${program} ${command}${global ? ' -g' : ''}`, rest.filter(word => !isInstalled(word)))
97
+ if (installing && anew(argv) !== null) return unlocked(`${program} ${command}${anew(argv)}`)
98
+ return updates.test(command) ? unlocked(`${program} ${command}`) : []
99
+ }
100
+ const pnpm = pnpmLike('pnpm', 'dlx', false, argv => argv.includes('--no-frozen-lockfile') ? ' --no-frozen-lockfile' : null, /^(?:update|up|upgrade)$/u)
101
+ const bun = pnpmLike('bun', 'x', true, argv => argv.some(word => word === '--frozen-lockfile' || word === '--ci') ? null : '', /^update$/u)
102
+
103
+ function yarn(argv) {
104
+ const [command = '', ...rest] = positional(argv, 1)
105
+ if (command === 'dlx') return execPackages('yarn dlx', argv, argv.indexOf('dlx') + 1, false)
106
+ if (command === 'create') return created('yarn create', rest[0])
107
+ if (command === 'global' && rest[0] === 'add') return installs('yarn global add', rest.slice(1))
108
+ if (command === 'add') return installs('yarn add', rest.filter(word => !isInstalled(word)))
109
+ const frozen = argv.some(word => /^--(?:frozen-lockfile|immutable)$/u.test(word))
110
+ if ((command === '' || command === 'install') && !frozen) return unlocked(command ? 'yarn install' : 'yarn')
111
+ return /^(?:upgrade|up)$/u.test(command) ? unlocked(`yarn ${command}`) : []
112
+ }
113
+
114
+ // The tools that run a package (whether one installed first), and the rest.
115
+ const EXECS = new Map([['npx', true], ['bunx', true], ['pnpx', false]])
116
+ const TOOLS = new Map([['npm', npm], ['pnpm', pnpm], ['yarn', yarn], ['bun', bun]])
117
+
118
+ // What a command of npm's tools fetches, by its program's name: packages and
119
+ // installs resolving anew.
120
+ export function npmToolFinds(program, argv) {
121
+ if (EXECS.has(program)) return execPackages(program, argv, 1, EXECS.get(program))
122
+ return TOOLS.get(program)?.(argv) ?? []
123
+ }