@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.
- package/LICENSE-APACHE +202 -0
- package/README.md +16 -0
- package/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/dependency-paths.d.ts +3 -0
- package/client/dependency-paths.js +36 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +115 -0
- package/common/bundle-commit.d.ts +1 -0
- package/common/bundle-commit.js +4 -0
- package/common/bundle-compare-diff.d.ts +3 -0
- package/common/bundle-compare-diff.js +357 -0
- package/common/bundle-compare-inputs.d.ts +2 -0
- package/common/bundle-compare-inputs.js +65 -0
- package/common/bundle-compare-versions.js +172 -0
- package/common/bundle-comparison.d.ts +8 -0
- package/common/bundle-comparison.js +62 -0
- package/common/bundle-metadata.d.ts +15 -0
- package/common/bundle-metadata.js +226 -0
- package/common/bundle-pkg-of.d.ts +1 -0
- package/common/bundle-pkg-of.js +90 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-source-links.d.ts +6 -0
- package/common/bundle-source-links.js +216 -0
- package/common/bundle-sourcemap.d.ts +10 -0
- package/common/bundle-sourcemap.js +100 -0
- package/common/bundle-sources.d.ts +7 -0
- package/common/bundle-sources.js +333 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +65 -0
- package/common/default-scan-models.ts +31 -0
- package/common/dependency-reviews.d.ts +18 -0
- package/common/dependency-reviews.js +64 -0
- package/common/finding-duplicates.js +58 -0
- package/common/finding-filters.js +274 -0
- package/common/finding-id.js +47 -0
- package/common/github-issue-labels.d.ts +1 -0
- package/common/github-issue-labels.js +11 -0
- package/common/github-pr.ts +56 -0
- package/common/gzip.js +59 -0
- package/common/line-diff.d.ts +8 -0
- package/common/line-diff.js +57 -0
- package/common/linked-workspace-groups.js +63 -0
- package/common/managed/agent-tasks.ts +292 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/finding-detail.d.ts +2 -0
- package/common/managed/finding-detail.js +32 -0
- package/common/managed/github-agents.ts +114 -0
- package/common/managed/github-logins.ts +49 -0
- package/common/managed/npm-packages.d.ts +5 -0
- package/common/managed/npm-packages.js +34 -0
- package/common/managed/permissions.ts +31 -0
- package/common/managed/report-content.ts +72 -0
- package/common/managed/report-filter.ts +109 -0
- package/common/managed/repository-alias.ts +65 -0
- package/common/managed/roles.ts +35 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +347 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/team-app.d.ts +5 -0
- package/common/managed/team-app.js +31 -0
- package/common/managed/triage.ts +83 -0
- package/common/managed/upload-seal.ts +126 -0
- package/common/pretty-print.d.ts +5 -0
- package/common/pretty-print.js +24 -0
- package/common/revalidation-conflicts.js +48 -0
- package/common/revalidation-input-groups.js +56 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/security-links.d.ts +5 -0
- package/common/security-links.js +33 -0
- package/common/server-info.ts +46 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/common/workspace-app.js +39 -0
- package/common/workspace-groups.js +176 -0
- package/out/activity.js +4 -0
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2WDZPXVL.js +3 -0
- package/out/chunk-2XUEZHIP.js +28 -0
- package/out/chunk-2ZDZWLFY.js +1 -0
- package/out/chunk-4HI2MSAV.js +17 -0
- package/out/chunk-4XMP7YVH.js +36 -0
- package/out/chunk-6LNQTKAV.js +52 -0
- package/out/chunk-6S2U2UYG.js +232 -0
- package/out/chunk-75YLHBCM.js +3 -0
- package/out/chunk-7BKKYVWR.js +7 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-7VAWINQF.js +6 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-FC65TPUB.js +1 -0
- package/out/chunk-FKBXNUW4.js +1 -0
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-HTBYHSPG.js +1 -0
- package/out/chunk-IYPY47MC.js +1 -0
- package/out/chunk-IZ7POWI3.js +18 -0
- package/out/chunk-JCIIBLKR.js +1 -0
- package/out/chunk-NRPC74AM.js +1 -0
- package/out/chunk-OOJM4CTU.js +1 -0
- package/out/chunk-PBZPYIG7.js +1 -0
- package/out/chunk-PNHBZGDD.js +1 -0
- package/out/chunk-PWFUOLZK.js +17 -0
- package/out/chunk-PXK7EU4V.js +1 -0
- package/out/chunk-QB4CBOX2.js +1 -0
- package/out/chunk-R3NZFXVR.js +1 -0
- package/out/chunk-R5O6FAW4.js +2 -0
- package/out/chunk-SXV2T5C4.js +80 -0
- package/out/chunk-TQJDYHVE.js +2 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-UK7RGJT6.js +3 -0
- package/out/chunk-VFWMQ3LG.js +2 -0
- package/out/chunk-VH4XMZIW.js +2 -0
- package/out/chunk-WCPJDAYA.js +10 -0
- package/out/chunk-WFYLMF6Z.js +10 -0
- package/out/chunk-WYLQWYLA.js +1 -0
- package/out/client-managed-import.js +4 -0
- package/out/client-managed.js +33 -0
- package/out/client-sync.js +11 -17
- package/out/compare.js +35 -0
- package/out/dialogs.js +55 -0
- package/out/github-issue-authorized.html +3 -0
- package/out/github-issue-authorized.js +1 -0
- package/out/graph.js +17 -4
- package/out/index.html +90 -8
- package/out/prism.js +14 -10
- package/out/scan.js +2 -0
- package/out/stasis.svg +20 -0
- package/out/terminal.js +366 -39
- package/out/view.css +1 -1
- package/out/view.js +19 -144
- package/package.json +362 -59
- package/risk/actions/findings.js +161 -0
- package/risk/actions/resolve.js +219 -0
- package/risk/actions/scripts/commands.js +182 -0
- package/risk/actions/scripts/grade.js +113 -0
- package/risk/actions/scripts/packages.js +123 -0
- package/risk/actions/scripts/shell.js +169 -0
- package/risk/actions/uses.js +76 -0
- package/risk/actions/workflow.js +110 -0
- package/risk/files/characters.js +196 -0
- package/risk/files/hosts.js +205 -0
- package/risk/files/readability.js +177 -0
- package/risk/files/runs-code.js +615 -0
- package/risk/files/script.js +118 -0
- package/risk/files/stamps.js +47 -0
- package/risk/index.d.ts +254 -0
- package/risk/index.js +11 -0
- package/risk/npm/assess.js +285 -0
- package/risk/npm/combined.js +68 -0
- package/risk/npm/compare.js +257 -0
- package/risk/npm/dependencies.js +172 -0
- package/risk/npm/diff.js +79 -0
- package/risk/npm/downloads.js +84 -0
- package/risk/npm/files.js +445 -0
- package/risk/npm/held.js +63 -0
- package/risk/npm/install-scripts.js +34 -0
- package/risk/npm/malware.js +36 -0
- package/risk/npm/manifest.js +65 -0
- package/risk/npm/new-dependency.js +60 -0
- package/risk/npm/old-version.js +108 -0
- package/risk/npm/registry.js +388 -0
- package/risk/npm/release.js +85 -0
- package/risk/npm/score.js +59 -0
- package/risk/npm/specs.js +124 -0
- package/risk/npm/tarball.js +96 -0
- package/server-common/database-config.ts +16 -0
- package/server-common/initialize.ts +18 -0
- package/server-common/npm-advisories.ts +101 -0
- package/{server → server-common}/origin.ts +5 -5
- package/server-common/reap.ts +48 -0
- package/server-common/scan-config.ts +19 -0
- package/server-common/standalone.ts +29 -0
- package/server-common/storage-crypto.ts +182 -0
- package/server-common/storage-log.ts +38 -0
- package/server-common/vercel-blob.ts +103 -0
- package/server-e2e/app.ts +487 -0
- package/{server → server-e2e}/bus-receiver.ts +2 -1
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +50 -42
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +1 -1
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +7 -7
- package/{server → server-e2e}/http.ts +44 -13
- package/{server → server-e2e}/hub.ts +40 -9
- package/server-e2e/index.ts +17 -0
- package/server-e2e/lifecycle.ts +95 -0
- package/{server → server-e2e}/neon-driver.ts +2 -2
- package/{server → server-e2e}/npm-proxy.ts +12 -145
- package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
- package/{server → server-e2e}/objstore/blob.ts +1 -1
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +9 -8
- package/{server → server-e2e}/objstore/init.ts +19 -27
- package/{server → server-e2e}/objstore/reaper.ts +1 -1
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +3 -3
- package/{server → server-e2e}/objstore/store-neon.ts +6 -10
- package/{server → server-e2e}/objstore/store.ts +7 -7
- package/{server → server-e2e}/objstore/tokens.ts +2 -2
- package/{server → server-e2e}/pubsub.ts +12 -9
- package/{server → server-e2e}/sign.ts +4 -4
- package/{server → server-e2e}/sse-server.ts +60 -33
- package/{server → server-e2e}/static.ts +31 -21
- package/{server → server-e2e}/sync-handlers.ts +64 -30
- package/{server → server-e2e}/ws-server.ts +20 -9
- package/server-managed/activity.ts +258 -0
- package/server-managed/agent-merges.ts +109 -0
- package/server-managed/app-agents.ts +21 -0
- package/server-managed/avatar-store.ts +13 -0
- package/server-managed/blob-store.ts +17 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-advisories.ts +120 -0
- package/server-managed/bundle-advisory-inventory.ts +77 -0
- package/server-managed/bundle-build-diagnostics.d.ts +10 -0
- package/server-managed/bundle-build-diagnostics.js +23 -0
- package/server-managed/bundle-build-leases.ts +41 -0
- package/server-managed/bundle-build-worker.js +115 -0
- package/server-managed/bundle-build.ts +200 -0
- package/server-managed/bundle-cache.ts +211 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-commits.ts +140 -0
- package/server-managed/bundle-store.ts +24 -0
- package/server-managed/bundle-summary-cache.ts +130 -0
- package/server-managed/bundle.ts +119 -0
- package/server-managed/cache-storage.ts +20 -0
- package/server-managed/cli.js +21 -0
- package/server-managed/combined.ts +47 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/commit-coauthors.ts +106 -0
- package/server-managed/commit-lockfiles.ts +73 -0
- package/server-managed/comparison.ts +41 -0
- package/server-managed/config.ts +164 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1923 -0
- package/server-managed/db-neon.ts +458 -0
- package/server-managed/db-schema.ts +238 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +142 -0
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +100 -0
- package/server-managed/dependency-reviews.ts +346 -0
- package/server-managed/github-accounts.ts +60 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +415 -0
- package/server-managed/github-errors.ts +70 -0
- package/server-managed/github-graphql.ts +39 -0
- package/server-managed/github-issue-links.ts +140 -0
- package/server-managed/github-issue-oauth.ts +47 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-link-oauth.ts +69 -0
- package/server-managed/github-links.ts +204 -0
- package/server-managed/github-metadata.ts +213 -0
- package/server-managed/github-oauth.ts +238 -0
- package/server-managed/github-pulls.ts +227 -0
- package/server-managed/github-requests.ts +39 -0
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http-response.ts +76 -0
- package/server-managed/http.ts +5329 -0
- package/server-managed/import-triage.ts +51 -0
- package/server-managed/index.ts +192 -0
- package/server-managed/kept-briefly.ts +37 -0
- package/server-managed/kept.ts +28 -0
- package/server-managed/link-reports.ts +162 -0
- package/server-managed/locked-packages.ts +129 -0
- package/server-managed/lockfile-scope.ts +49 -0
- package/server-managed/lru.ts +30 -0
- package/server-managed/managed-issues.ts +78 -0
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/management.ts +99 -0
- package/server-managed/npm-delta-worker.js +23 -0
- package/server-managed/npm-delta.ts +194 -0
- package/server-managed/npm-insights.ts +449 -0
- package/server-managed/npm-kept.ts +215 -0
- package/server-managed/npm-loads.ts +264 -0
- package/server-managed/npm-manifest.ts +115 -0
- package/server-managed/npm-packages.ts +454 -0
- package/server-managed/npm-provenance.ts +123 -0
- package/server-managed/npm-risk.ts +285 -0
- package/server-managed/object-storage-disk.ts +148 -0
- package/server-managed/object-storage-vercel.ts +67 -0
- package/server-managed/object-storage.ts +75 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/pretty-print.ts +434 -0
- package/server-managed/public-workspace.ts +238 -0
- package/server-managed/pull-comment-reviews.ts +161 -0
- package/server-managed/pull-dependencies.ts +348 -0
- package/server-managed/pull-dependency-reviews.ts +328 -0
- package/server-managed/pull-reviews.ts +205 -0
- package/server-managed/queue.ts +39 -0
- package/server-managed/release-commits.ts +84 -0
- package/server-managed/rename-diff-worker.js +26 -0
- package/server-managed/rename-diff.ts +279 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +10 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +178 -0
- package/server-managed/report-store.ts +85 -0
- package/server-managed/repository-actions.ts +218 -0
- package/server-managed/repository-agents.ts +355 -0
- package/server-managed/repository-aliases.ts +91 -0
- package/server-managed/repository-browser.ts +294 -0
- package/server-managed/repository-changes.ts +284 -0
- package/server-managed/repository-code.ts +215 -0
- package/server-managed/repository-dependency-reviews.ts +165 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-history.ts +432 -0
- package/server-managed/repository-issues.ts +195 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/repository-pulls.ts +156 -0
- package/server-managed/repository-pulse.ts +460 -0
- package/server-managed/repository-risk-assess.ts +105 -0
- package/server-managed/repository-risk-audit.ts +79 -0
- package/server-managed/repository-risk-read.ts +170 -0
- package/server-managed/repository-risk.ts +491 -0
- package/server-managed/repository-squashes.ts +168 -0
- package/server-managed/request-timing.ts +63 -0
- package/server-managed/reviewed-dependencies.ts +150 -0
- package/server-managed/revisions.ts +35 -0
- package/server-managed/scan-downloads.ts +87 -0
- package/server-managed/session.ts +132 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/solidity-entry-points.ts +71 -0
- package/server-managed/sql-postgres.ts +34 -0
- package/server-managed/sql.ts +88 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +183 -0
- package/server-managed/storage-encryption.ts +290 -0
- package/server-managed/storage-maintenance.ts +259 -0
- package/server-managed/storage-payload.ts +121 -0
- package/server-managed/storage-stores.ts +91 -0
- package/server-managed/storage.ts +82 -0
- package/server-managed/team-app.ts +134 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +180 -0
- package/server-managed/team-npm-scopes.ts +106 -0
- package/server-managed/team-reports.ts +307 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/type-only-worker.js +19 -0
- package/server-managed/type-only.ts +105 -0
- package/server-managed/uploads.ts +106 -0
- package/server-managed/upstream-cache.ts +243 -0
- package/server-managed/view-as.ts +58 -0
- package/server-managed/workspace-shares.ts +169 -0
- package/server.ts +70 -0
- package/server/index.ts +0 -480
- package/server/lifecycle.ts +0 -208
- /package/{LICENSE → LICENSE-MIT} +0 -0
- /package/{server → server-e2e}/auth.ts +0 -0
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/objstore/sign.ts +0 -0
- /package/{server → server-e2e}/peer.ts +0 -0
- /package/{server → server-e2e}/sse-session.ts +0 -0
- /package/{server → server-e2e}/util.ts +0 -0
- /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
|
+
}
|