workspace-conformance 1.5.0 → 1.6.0
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/README.md +6 -1
- package/dist/cli.js +91 -8
- package/dist/index.cjs +92 -8
- package/dist/index.d.cts +31 -1
- package/dist/index.d.ts +31 -1
- package/dist/index.js +92 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -224,6 +224,7 @@ Every workflow check takes `workflows` (globs of the files to read, `.github/wor
|
|
|
224
224
|
| `workflow-runner-resolution` | A custom or self-hosted runner label is not named literally in several jobs; a resolver job has a `timeout-minutes` and its output a literal hosted fallback. | `repeated-label`, `resolver-no-timeout`, `resolver-no-fallback`, `resolver-fallback-not-hosted` |
|
|
225
225
|
| `workflow-version-single-source` | A setup step does not hold a literal runtime version when `.nvmrc` or `.tool-versions` already does. | `literal-version` |
|
|
226
226
|
| `workflow-credentials` | Attestation steps have `id-token: write` and `attestations: write`; a publish that passes no token blanks the token variables, or at least requests provenance so that a fallback publish is traceable. | `attestation-permissions`, `tokenless-publish-unprotected` |
|
|
227
|
+
| `workflow-release-job` | The release job is the only job granted `id-token: write`, runs in a deployment environment and checks out with `persist-credentials: false`; no job holding `id-token: write` passes an npm token from a secret. | `id-token-workflow-level`, `id-token-outside-release`, `missing-environment`, `persisted-credentials`, `token-beside-oidc` |
|
|
227
228
|
| `workflow-repository-dispatch` | A `repository_dispatch` handler does not push to the default branch, auto-merge without a required-checks rule, push or open pull requests as `GITHUB_TOKEN`, or dispatch to another repository with it. | `pushes-default-branch`, `auto-merge-without-required-checks`, `default-token-triggers-nothing`, `dispatch-other-repository-with-default-token` |
|
|
228
229
|
| `workflow-update-bot-cooldown` | Dependabot has a `cooldown` in every update entry, and Renovate sets `minimumReleaseAge`. | `dependabot-no-cooldown`, `renovate-no-minimum-release-age` |
|
|
229
230
|
| `workflow-merge-group` | A workflow that runs for `pull_request` also has a `merge_group` trigger. | `missing-trigger` |
|
|
@@ -241,6 +242,10 @@ Codes are `<check>/<code>`, so `workflow-job-ordering/release-before-deploy`. Th
|
|
|
241
242
|
|
|
242
243
|
**`workflow-credentials`**: an attestation step (`actions/attest`, `actions/attest-build-provenance`, `actions/attest-sbom`) needs `id-token: write` and `attestations: write` in the job's effective permissions. Permissions declared nowhere are the repository's default, which the file cannot show and which does not grant `id-token`, so they are reported. A job with `id-token: write` that runs a publish command (`npm`, `pnpm` or `yarn npm publish`, with options such as `-r` or `--filter <package>` before `publish`, `semantic-release`, `changeset publish`, `lerna publish`, bare or behind `pnpm`, `pnpm exec`, `yarn` or `npx`) and passes no token must set `NODE_AUTH_TOKEN` and `NPM_TOKEN` to the empty string (at step, job or workflow level) or request provenance (`--provenance`, `NPM_CONFIG_PROVENANCE: true`, or `publishConfig.provenance: true` in the `package.json` of the working directory); provenance does not stop an inherited token being used, it makes that publish traceable. Limits: commands are read as text where a command starts, so a script file or composite action is not seen; provenance set in another `package.json` is not read; a job is tokenless only if no token variable has a non-empty value anywhere in it; a job that calls a reusable workflow is not read. `registry-url` is deliberately not reported, see below.
|
|
243
244
|
|
|
245
|
+
**`workflow-release-job`** takes `releaseJobs` (`release`, the same default as `workflow-job-ordering`). npm binds a trusted publisher to the repository and the workflow file, not to a branch ([trusted publishers](https://docs.npmjs.com/trusted-publishers), [archived](https://web.archive.org/web/20260921070919/https://docs.npmjs.com/trusted-publishers/)), so anyone who can push a branch can edit that branch's copy of the workflow, remove the `if` that keeps publishing to the default branch, and publish. The protection has to live outside the file: a deployment environment whose deployment branch policy allows only the default branch, named as the environment of the trusted publisher on npm, so an OIDC token minted anywhere else is refused. In a workflow with a release job, `id-token-workflow-level` reports workflow-level `permissions` that grant `id-token: write` (directly or through `write-all`), since every job that declares none inherits it, and `id-token-outside-release` reports any other job that declares it, a job that calls a reusable workflow included, because the OIDC identity is the publishing credential and only the release job should be able to mint it. `missing-environment` reports a release job with no `environment` (a name, or a mapping with a `name`). `persisted-credentials` reports a release job's `actions/checkout` that does not set `persist-credentials: false` literally, since the job token, a `token` input or an `ssh-key` would otherwise stay in git config for every later step, the dependency install and third-party actions included. There is no exception for a release that pushes: semantic-release pushes to its `repositoryUrl` as written and, only when that fails, to the same URL with `GITHUB_TOKEN` or `GH_TOKEN` from the environment embedded (`lib/get-git-auth-url.js` of `semantic-release`), so it never needs the checkout's credentials. A deploy-key release job, the shape ExaDev's own release jobs use, checks out with `persist-credentials: false`, writes the key to a file in the step before the release, and hands it to git only through the `GIT_SSH_COMMAND` in the `env` of the steps that push; it is accepted as written, and the clean fixture holds one. In every workflow, `token-beside-oidc` reports a job whose effective permissions grant `id-token: write` and whose workflow, job or step `env` sets `NODE_AUTH_TOKEN` or `NPM_TOKEN` from the `secrets` context: a long-lived token beside the OIDC identity is a second way to publish that no environment protection or trusted publisher setting covers. Limits: the environment's protection rules and the npm trusted publisher settings cannot be seen in the file and are not read (`missing-environment` checks only that an environment is named); a release job that calls a reusable workflow is judged only for `id-token`, since `environment` and the steps belong to the called workflow; a token passed through `with`, written to an `.npmrc` by a script, or set inside a composite action is not seen.
|
|
246
|
+
|
|
247
|
+
What zizmor already covers, and what this check adds, as observed with zizmor 1.30.1 on this check's fixtures ([audit rules](https://docs.zizmor.sh/audits/), [archived](https://web.archive.org/web/20260929212113/https://docs.zizmor.sh/audits/)): `excessive-permissions` reports a workflow-level `id-token: write`, but not `id-token: write` declared by a second job, which only `undocumented-permissions` mentions when the grant has no comment. `artipacked` reports every `actions/checkout` without `persist-credentials: false`, in every job and every persona, at `help` severity for `actions/checkout` v6 and later, which keeps the credentials under `$RUNNER_TEMP` rather than in `.git/config`; this check reports it as a violation in the release job, where a step that reads git config has the publishing job's push credential. `secrets-outside-env` (auditor persona only) reports a `secrets` reference in a job with no environment, so it catches a missing environment only when the release job reads a secret; a tokenless OIDC release job with no environment produces no zizmor finding at all. `use-trusted-publishing` reports an `npm publish` that uses a token, but is silent once the job also has `id-token: write`, so an `NPM_TOKEN` from a secret beside `id-token: write` in a job with an environment produces no zizmor finding. No zizmor audit relates the grants of one job to the others in a workflow, or requires an environment for the job that holds `id-token: write`.
|
|
248
|
+
|
|
244
249
|
**`workflow-repository-dispatch`** takes `defaultBranches` (`main` and `master`; the `github.event.repository.default_branch` expression always counts) and `assumeRequiredChecks`. It reads workflows with a `repository_dispatch` trigger, which anyone with write access can start through the API. `pushes-default-branch` reports a `git push` whose destination is the default branch (a leading `+` is ignored, a bare `HEAD` is the checked-out branch, quotes around a word are removed, `git -C <directory>` and `git -c <setting>` before `push` are read, and options that take a value such as `-o` are skipped), a push with no refspec while the default branch is checked out (no `git checkout -b` or `git switch -c` earlier in the job, no non-default `ref` on the checkout) and `stefanzweifel/git-auto-commit-action` on the default branch. `auto-merge-without-required-checks` reports `gh pr merge --auto`, which merges at once when the base branch requires no checks; the file cannot show the rule, so `assumeRequiredChecks: true` states it and `settings-required-checks` verifies it. `default-token-triggers-nothing` reports a push whose checkout kept the default token (no `token` input; a push to a URL that carries its own credentials does not use it), `gh pr create` with `GITHUB_TOKEN` and `peter-evans/create-pull-request` with the default token: events created with `GITHUB_TOKEN` start no workflows, so the result is never checked. `dispatch-other-repository-with-default-token` reports a dispatch (`gh api repos/<o>/<r>/dispatches`, `gh workflow run -R`, `peter-evans/repository-dispatch`) to a repository other than `github.repository` with `GITHUB_TOKEN`, whose access is limited to its own repository. Dispatching in the same repository with `GITHUB_TOKEN` is not reported, because GitHub documents `workflow_dispatch` and `repository_dispatch` as the events that do create runs when made with it ([events that trigger workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows), [archived](https://web.archive.org/web/20261001051219/https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows)). Limits: commands are read as text, so a script file is not seen; a dispatch target written literally as this repository's own name is reported, since the file does not know the name.
|
|
245
250
|
|
|
246
251
|
**`workflow-update-bot-cooldown`** takes `dependabot` (`.github/dependabot.yml`, else `.yaml`) and `renovate` (`renovate.json`, `.renovaterc.json`, `.renovaterc` and `.github/renovate.json`, the first that exists). Every Dependabot `updates` entry needs a `cooldown` with `default-days` or one of the `semver-*-days` above zero. Renovate needs `minimumReleaseAge` at the top level or in a `packageRules` entry, as a duration above zero (`0 days` and `null` wait for nothing). Limits: Dependabot's cooldown does not apply to security updates; a Renovate preset is not resolved (an `extends` entry counts only when its name contains `minimumReleaseAge`); a Renovate config is read as JSON with comments and trailing commas, so other JSON5 syntax and a `renovate` key in `package.json` are not read. A repository with neither bot has nothing to report; a file named in the options that does not exist fails the run.
|
|
@@ -306,7 +311,7 @@ it('conforms to the workspace layout', async () => {
|
|
|
306
311
|
});
|
|
307
312
|
```
|
|
308
313
|
|
|
309
|
-
Every check is also exported as a function taking `{ cwd, options }` (and `layout` for the import checks): `aggregateMappers`, `codecPairs`, `commandTypes`, `derivedTypes`, `importUphill`, `importRankSkip`, `importCrossSlice`, `importIsolatedGroups`, `importCycles`, `instructionSymlinks`, `singleStorybook`, `commitTypes`, `dockerfilePackageManager`, `migrationsDirectory`, `eslint`, the workflow checks (`workflowJobOrdering`, `workflowSkippableJobs`, `workflowRunnerResolution`, `workflowVersionSingleSource`, `workflowCredentials`, `workflowRepositoryDispatch`, `workflowUpdateBotCooldown`, `workflowMergeGroup` and `workflowActionPinning`) and, taking `{ cwd, options, github }`, the settings checks (`settingsMergeMethods`, `settingsRequiredChecks` and `settingsReviewThreadResolution`). These take options and a layout as already validated and do not check them again; go through `runChecks` for validation. `parseWorkflow` and `loadWorkflows` expose the workflow model the workflow checks read. A check is a function that returns `Promise<readonly Violation[]>`, where a `Violation` is `{ code, message, file, location? }`.
|
|
314
|
+
Every check is also exported as a function taking `{ cwd, options }` (and `layout` for the import checks): `aggregateMappers`, `codecPairs`, `commandTypes`, `derivedTypes`, `importUphill`, `importRankSkip`, `importCrossSlice`, `importIsolatedGroups`, `importCycles`, `instructionSymlinks`, `singleStorybook`, `commitTypes`, `dockerfilePackageManager`, `migrationsDirectory`, `eslint`, the workflow checks (`workflowJobOrdering`, `workflowSkippableJobs`, `workflowRunnerResolution`, `workflowVersionSingleSource`, `workflowCredentials`, `workflowReleaseJob`, `workflowRepositoryDispatch`, `workflowUpdateBotCooldown`, `workflowMergeGroup` and `workflowActionPinning`) and, taking `{ cwd, options, github }`, the settings checks (`settingsMergeMethods`, `settingsRequiredChecks` and `settingsReviewThreadResolution`). These take options and a layout as already validated and do not check them again; go through `runChecks` for validation. `parseWorkflow` and `loadWorkflows` expose the workflow model the workflow checks read. A check is a function that returns `Promise<readonly Violation[]>`, where a `Violation` is `{ code, message, file, location? }`.
|
|
310
315
|
|
|
311
316
|
### Proving a handler map is exhaustive
|
|
312
317
|
|
package/dist/cli.js
CHANGED
|
@@ -1098,6 +1098,9 @@ function runsOnOf(reader, value, what) {
|
|
|
1098
1098
|
group
|
|
1099
1099
|
};
|
|
1100
1100
|
}
|
|
1101
|
+
function environmentOf(reader, value, what) {
|
|
1102
|
+
return isRecord(value) ? reader.text(value["name"], `${what}.name`) : reader.text(value, what);
|
|
1103
|
+
}
|
|
1101
1104
|
function stepOf(reader, value, jobPath, index) {
|
|
1102
1105
|
const path = [
|
|
1103
1106
|
...jobPath,
|
|
@@ -1130,6 +1133,7 @@ function jobOf(reader, id, value) {
|
|
|
1130
1133
|
needs: reader.list(job["needs"], `${what}.needs`),
|
|
1131
1134
|
condition: reader.text(job["if"], `${what}.if`),
|
|
1132
1135
|
permissions: permissionsOf(reader, job["permissions"], `${what}.permissions`),
|
|
1136
|
+
environment: environmentOf(reader, job["environment"], `${what}.environment`),
|
|
1133
1137
|
env: reader.texts(job["env"], `${what}.env`),
|
|
1134
1138
|
runsOn: runsOnOf(reader, job["runs-on"], `${what}.runs-on`),
|
|
1135
1139
|
timeoutMinutes: reader.text(job["timeout-minutes"], `${what}.timeout-minutes`),
|
|
@@ -1165,6 +1169,7 @@ function parseWorkflow(file, source) {
|
|
|
1165
1169
|
triggers,
|
|
1166
1170
|
triggerLocations: triggerLocationsOf(reader, triggers, root["on"]),
|
|
1167
1171
|
permissions: permissionsOf(reader, root["permissions"], "permissions"),
|
|
1172
|
+
permissionsLocation: root["permissions"] === void 0 ? void 0 : reader.location(["permissions"]),
|
|
1168
1173
|
env: reader.texts(root["env"], "env"),
|
|
1169
1174
|
jobs: Object.entries(reader.record(root["jobs"], "jobs")).map(([id, job]) => jobOf(reader, id, job)),
|
|
1170
1175
|
callOutputs: Object.fromEntries(Object.entries(outputs).map(([name, output]) => [name, reader.text(reader.record(output, `on.workflow_call.outputs.${name}`)["value"], `on.workflow_call.outputs.${name}.value`) ?? ""]))
|
|
@@ -1343,6 +1348,14 @@ function callsAlways(condition) {
|
|
|
1343
1348
|
* The default ids of junction jobs, shared by the checks that look for one.
|
|
1344
1349
|
*/
|
|
1345
1350
|
const DEFAULT_JUNCTION_JOBS = ["required-checks"];
|
|
1351
|
+
/**
|
|
1352
|
+
* The default ids of release jobs, shared by the checks that look for one.
|
|
1353
|
+
*/
|
|
1354
|
+
const DEFAULT_RELEASE_JOBS = ["release"];
|
|
1355
|
+
/**
|
|
1356
|
+
* The variables a token for the npm registry is passed in.
|
|
1357
|
+
*/
|
|
1358
|
+
const REGISTRY_TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
|
|
1346
1359
|
//#endregion
|
|
1347
1360
|
//#region src/checks/settings-required-checks.ts
|
|
1348
1361
|
/**
|
|
@@ -1478,10 +1491,6 @@ const PACKAGE_MANAGER_OPTIONS = String.raw`(?:\s+(?:(?:--filter|-F|--workspace|-
|
|
|
1478
1491
|
* Commands that publish a package to a registry.
|
|
1479
1492
|
*/
|
|
1480
1493
|
const PUBLISH_COMMAND = new RegExp(String.raw`^(?:(?:npm|pnpm|yarn)${PACKAGE_MANAGER_OPTIONS}(?:\s+npm)?\s+publish|(?:(?:pnpm|yarn)(?:\s+exec)?\s+|npx\s+)?(?:semantic-release|changeset\s+publish|lerna\s+publish))(?:\s|$)`, "u");
|
|
1481
|
-
/**
|
|
1482
|
-
* The variables a token for the registry is passed in.
|
|
1483
|
-
*/
|
|
1484
|
-
const TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
|
|
1485
1494
|
const PROVENANCE_FLAG = /--provenance(?:=true)?(?:\s|$)/u;
|
|
1486
1495
|
/**
|
|
1487
1496
|
* Whether the `package.json` in the working directory asks for provenance with `publishConfig.provenance: true`, which applies to every publish of that package.
|
|
@@ -1505,7 +1514,7 @@ function attestationViolations(workflow, job) {
|
|
|
1505
1514
|
* Whether the job passes a registry token to a step, which makes its publish not tokenless: a token variable with a value other than the empty string, anywhere in the environment.
|
|
1506
1515
|
*/
|
|
1507
1516
|
function passesToken(workflow, job) {
|
|
1508
|
-
return [void 0, ...job.steps].some((step) =>
|
|
1517
|
+
return [void 0, ...job.steps].some((step) => REGISTRY_TOKEN_VARIABLES.some((variable) => (effectiveEnv(workflow, job, step)[variable] ?? "") !== ""));
|
|
1509
1518
|
}
|
|
1510
1519
|
function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
|
|
1511
1520
|
if (accessOf(effectivePermissions(workflow, job), "id-token") !== "write" || passesToken(workflow, job)) return [];
|
|
@@ -1513,9 +1522,9 @@ function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
|
|
|
1513
1522
|
const publishes = scriptCommands(step.run).filter((line) => PUBLISH_COMMAND.test(line));
|
|
1514
1523
|
if (publishes.length === 0) return [];
|
|
1515
1524
|
const env = effectiveEnv(workflow, job, step);
|
|
1516
|
-
const blanked =
|
|
1525
|
+
const blanked = REGISTRY_TOKEN_VARIABLES.every((variable) => env[variable] === "");
|
|
1517
1526
|
const provenance = provenanceInManifest || publishes.some((line) => PROVENANCE_FLAG.test(line)) || env["NPM_CONFIG_PROVENANCE"] === "true";
|
|
1518
|
-
return blanked || provenance ? [] : [workflowViolation(workflow, "workflow-credentials/tokenless-publish-unprotected", `job '${job.id}' publishes with an OIDC identity and no token, but does not set ${
|
|
1527
|
+
return blanked || provenance ? [] : [workflowViolation(workflow, "workflow-credentials/tokenless-publish-unprotected", `job '${job.id}' publishes with an OIDC identity and no token, but does not set ${REGISTRY_TOKEN_VARIABLES.join(" and ")} to the empty string, so a token inherited from the environment is used when the OIDC exchange fails; set them to the empty string to prevent that, or request provenance so that such a publish at least carries a traceable attestation`, step.location)];
|
|
1519
1528
|
});
|
|
1520
1529
|
}
|
|
1521
1530
|
/**
|
|
@@ -1533,7 +1542,6 @@ const workflowCredentials = async ({ cwd, options }) => {
|
|
|
1533
1542
|
};
|
|
1534
1543
|
//#endregion
|
|
1535
1544
|
//#region src/checks/workflow-job-ordering.ts
|
|
1536
|
-
const DEFAULT_RELEASE_JOBS = ["release"];
|
|
1537
1545
|
const DEFAULT_DEPLOY_JOBS = ["deploy"];
|
|
1538
1546
|
const DEFAULT_DOCS_DEPLOY_JOBS = ["docs-deploy"];
|
|
1539
1547
|
const DEFAULT_BRANCH = "main";
|
|
@@ -1681,6 +1689,71 @@ const workflowMergeGroup = async ({ cwd, options }) => (await loadWorkflows(cwd,
|
|
|
1681
1689
|
return location === void 0 || workflow.triggers.has("merge_group") ? [] : [workflowViolation(workflow, "workflow-merge-group/missing-trigger", "the workflow runs for pull requests but has no merge_group trigger, so its checks do not report for a pull request in the merge queue", location)];
|
|
1682
1690
|
});
|
|
1683
1691
|
//#endregion
|
|
1692
|
+
//#region src/checks/workflow-release-job.ts
|
|
1693
|
+
const CHECKOUT_ACTION$1 = "actions/checkout";
|
|
1694
|
+
/**
|
|
1695
|
+
* A value that reads the `secrets` context inside an expression.
|
|
1696
|
+
*/
|
|
1697
|
+
const FROM_SECRET = /\$\{\{[^}]*\bsecrets\./u;
|
|
1698
|
+
/**
|
|
1699
|
+
* The release jobs named for a message: `release job 'a'` or `release jobs 'a' and 'b'`.
|
|
1700
|
+
*/
|
|
1701
|
+
function releaseJobsText(releases) {
|
|
1702
|
+
const ids = releases.map((job) => `'${job.id}'`);
|
|
1703
|
+
return `release job${ids.length === 1 ? "" : "s"} ${ids.join(" and ")}`;
|
|
1704
|
+
}
|
|
1705
|
+
function grantsIdToken(permissions) {
|
|
1706
|
+
return accessOf(permissions, "id-token") === "write";
|
|
1707
|
+
}
|
|
1708
|
+
function idTokenViolations(workflow, releases) {
|
|
1709
|
+
const releaseIds = new Set(releases.map((job) => job.id));
|
|
1710
|
+
const named = releaseJobsText(releases);
|
|
1711
|
+
const workflowLevel = grantsIdToken(workflow.permissions) && workflow.permissionsLocation !== void 0 ? [workflowViolation(workflow, "workflow-release-job/id-token-workflow-level", `the workflow's permissions grant id-token: write to every job that declares none of its own; grant it on ${named} only`, workflow.permissionsLocation)] : [];
|
|
1712
|
+
const jobLevel = workflow.jobs.filter((job) => !releaseIds.has(job.id) && grantsIdToken(job.permissions)).map((job) => workflowViolation(workflow, "workflow-release-job/id-token-outside-release", `job '${job.id}' is granted id-token: write but is not a release job, so it can mint the OIDC identity npm trusted publishing accepts for this workflow; only ${named} may hold it`, job.location));
|
|
1713
|
+
return [...workflowLevel, ...jobLevel];
|
|
1714
|
+
}
|
|
1715
|
+
/**
|
|
1716
|
+
* What a checkout that does not set `persist-credentials: false` leaves in git config.
|
|
1717
|
+
*/
|
|
1718
|
+
function persistedCredential(step) {
|
|
1719
|
+
if (step.with["ssh-key"] !== void 0) return "the SSH key";
|
|
1720
|
+
return step.with["token"] === void 0 ? "the job token" : "the token it was given";
|
|
1721
|
+
}
|
|
1722
|
+
function releaseJobViolations(workflow, job) {
|
|
1723
|
+
if (job.uses !== void 0) return [];
|
|
1724
|
+
const environment = job.environment === void 0 ? [workflowViolation(workflow, "workflow-release-job/missing-environment", `release job '${job.id}' declares no environment, so nothing outside the workflow file stops a branch's copy of it publishing; run it in a protected environment restricted to the default branch`, job.location)] : [];
|
|
1725
|
+
const checkouts = job.steps.filter((step) => stepUses(step, CHECKOUT_ACTION$1) && step.with["persist-credentials"] !== "false").map((step) => workflowViolation(workflow, "workflow-release-job/persisted-credentials", `release job '${job.id}' checks out without persist-credentials: false, which leaves ${persistedCredential(step)} in git config for every later step`, step.location));
|
|
1726
|
+
return [...environment, ...checkouts];
|
|
1727
|
+
}
|
|
1728
|
+
/**
|
|
1729
|
+
* The token variables an environment sets from a secret.
|
|
1730
|
+
*/
|
|
1731
|
+
function secretTokens(env) {
|
|
1732
|
+
return REGISTRY_TOKEN_VARIABLES.filter((variable) => FROM_SECRET.test(env[variable] ?? ""));
|
|
1733
|
+
}
|
|
1734
|
+
function tokenBesideOidcViolations(workflow, job) {
|
|
1735
|
+
if (!grantsIdToken(effectivePermissions(workflow, job))) return [];
|
|
1736
|
+
const report = (variables, location) => variables.length === 0 ? [] : [workflowViolation(workflow, "workflow-release-job/token-beside-oidc", `job '${job.id}' holds id-token: write and passes ${variables.join(" and ")} from a secret, a long-lived npm token beside the OIDC identity; publish through trusted publishing and blank the token variables`, location)];
|
|
1737
|
+
return [...report(secretTokens(effectiveEnv(workflow, job, void 0)), job.location), ...job.steps.flatMap((step) => report(secretTokens(step.env), step.location))];
|
|
1738
|
+
}
|
|
1739
|
+
/**
|
|
1740
|
+
* npm binds a trusted publisher to the repository and the workflow file, not to a branch, so anyone who can push a branch can edit that branch's copy of the workflow, drop an `if` that keeps publishing to the default branch, and publish. What stops that has to live outside the file: a protected environment that only the default branch may deploy to, named in npm's trusted publisher too. This check holds the release job to the shape that makes that protection hold.
|
|
1741
|
+
*
|
|
1742
|
+
* In a workflow with a release job (by id, `releaseJobs`, `release` by default): `workflow-release-job/id-token-workflow-level`, the workflow's own `permissions` grant `id-token: write` (directly or through `write-all`), which every job that declares none inherits; `id-token-outside-release`, a job that is not a release job declares `id-token: write` itself, a job that calls a reusable workflow included; `missing-environment`, a release job declares no `environment`; `persisted-credentials`, a release job runs `actions/checkout` without `persist-credentials: false` (literally; an expression is unknown and reported), so the job token, a `token` input or an `ssh-key` stays in git config for every later step. A release job that pushes as a deploy key provisions the key in a step and hands it to git through that step's `GIT_SSH_COMMAND`, after a checkout that persists nothing, and is accepted as written; semantic-release authenticates its own push (`repositoryUrl` as written, else the URL with `GITHUB_TOKEN` or `GH_TOKEN` from the environment), so it needs nothing from the checkout.
|
|
1743
|
+
*
|
|
1744
|
+
* In every workflow: `token-beside-oidc`, a job whose effective permissions grant `id-token: write` sets `NODE_AUTH_TOKEN` or `NPM_TOKEN` from the `secrets` context in its workflow, job or step `env`.
|
|
1745
|
+
*
|
|
1746
|
+
* Limits: the environment's protection rules and npm's trusted publisher settings are not visible in the file and are not read; a release job that calls a reusable workflow is judged only for `id-token`, since `environment` and steps belong to the called workflow; a token passed through `with`, written to an `.npmrc` by a script, or inherited through a composite action is not seen.
|
|
1747
|
+
*/
|
|
1748
|
+
const workflowReleaseJob = async ({ cwd, options }) => {
|
|
1749
|
+
return (await loadWorkflows(cwd, options)).flatMap((workflow) => {
|
|
1750
|
+
const releases = jobsNamed(workflow, options.releaseJobs ?? DEFAULT_RELEASE_JOBS);
|
|
1751
|
+
const releaseFindings = releases.length === 0 ? [] : idTokenViolations(workflow, releases);
|
|
1752
|
+
const jobFindings = workflow.jobs.flatMap((job) => [...releases.includes(job) ? releaseJobViolations(workflow, job) : [], ...tokenBesideOidcViolations(workflow, job)]);
|
|
1753
|
+
return [...releaseFindings, ...jobFindings];
|
|
1754
|
+
});
|
|
1755
|
+
};
|
|
1756
|
+
//#endregion
|
|
1684
1757
|
//#region src/checks/workflow-repository-dispatch.ts
|
|
1685
1758
|
const DEFAULT_BRANCHES = ["main", "master"];
|
|
1686
1759
|
const DEFAULT_BRANCH_EXPRESSION = /\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}/u;
|
|
@@ -3380,6 +3453,12 @@ const registry = {
|
|
|
3380
3453
|
select: (checks) => enabledOptions(checks["workflow-credentials"]),
|
|
3381
3454
|
check: workflowCredentials
|
|
3382
3455
|
}),
|
|
3456
|
+
"workflow-release-job": plain({
|
|
3457
|
+
name: "workflow-release-job",
|
|
3458
|
+
description: "The release job alone holds id-token, runs in an environment and persists no credentials, and no job with id-token passes an npm token secret",
|
|
3459
|
+
select: (checks) => enabledOptions(checks["workflow-release-job"]),
|
|
3460
|
+
check: workflowReleaseJob
|
|
3461
|
+
}),
|
|
3383
3462
|
"workflow-repository-dispatch": plain({
|
|
3384
3463
|
name: "workflow-repository-dispatch",
|
|
3385
3464
|
description: "A repository_dispatch handler does not push to the default branch, auto-merge unguarded, or rely on the default token",
|
|
@@ -3574,6 +3653,10 @@ const checks = z.strictObject({
|
|
|
3574
3653
|
}))),
|
|
3575
3654
|
"workflow-version-single-source": z.exactOptional(enabled(z.strictObject(selection))),
|
|
3576
3655
|
"workflow-credentials": z.exactOptional(enabled(z.strictObject(selection))),
|
|
3656
|
+
"workflow-release-job": z.exactOptional(enabled(z.strictObject({
|
|
3657
|
+
...selection,
|
|
3658
|
+
releaseJobs: z.exactOptional(names)
|
|
3659
|
+
}))),
|
|
3577
3660
|
"workflow-repository-dispatch": z.exactOptional(enabled(z.strictObject({
|
|
3578
3661
|
...selection,
|
|
3579
3662
|
defaultBranches: z.exactOptional(names),
|
package/dist/index.cjs
CHANGED
|
@@ -2128,6 +2128,9 @@ function runsOnOf(reader, value, what) {
|
|
|
2128
2128
|
group
|
|
2129
2129
|
};
|
|
2130
2130
|
}
|
|
2131
|
+
function environmentOf(reader, value, what) {
|
|
2132
|
+
return isRecord(value) ? reader.text(value["name"], `${what}.name`) : reader.text(value, what);
|
|
2133
|
+
}
|
|
2131
2134
|
function stepOf(reader, value, jobPath, index) {
|
|
2132
2135
|
const path = [
|
|
2133
2136
|
...jobPath,
|
|
@@ -2160,6 +2163,7 @@ function jobOf(reader, id, value) {
|
|
|
2160
2163
|
needs: reader.list(job["needs"], `${what}.needs`),
|
|
2161
2164
|
condition: reader.text(job["if"], `${what}.if`),
|
|
2162
2165
|
permissions: permissionsOf(reader, job["permissions"], `${what}.permissions`),
|
|
2166
|
+
environment: environmentOf(reader, job["environment"], `${what}.environment`),
|
|
2163
2167
|
env: reader.texts(job["env"], `${what}.env`),
|
|
2164
2168
|
runsOn: runsOnOf(reader, job["runs-on"], `${what}.runs-on`),
|
|
2165
2169
|
timeoutMinutes: reader.text(job["timeout-minutes"], `${what}.timeout-minutes`),
|
|
@@ -2195,6 +2199,7 @@ function parseWorkflow(file, source) {
|
|
|
2195
2199
|
triggers,
|
|
2196
2200
|
triggerLocations: triggerLocationsOf(reader, triggers, root["on"]),
|
|
2197
2201
|
permissions: permissionsOf(reader, root["permissions"], "permissions"),
|
|
2202
|
+
permissionsLocation: root["permissions"] === void 0 ? void 0 : reader.location(["permissions"]),
|
|
2198
2203
|
env: reader.texts(root["env"], "env"),
|
|
2199
2204
|
jobs: Object.entries(reader.record(root["jobs"], "jobs")).map(([id, job]) => jobOf(reader, id, job)),
|
|
2200
2205
|
callOutputs: Object.fromEntries(Object.entries(outputs).map(([name, output]) => [name, reader.text(reader.record(output, `on.workflow_call.outputs.${name}`)["value"], `on.workflow_call.outputs.${name}.value`) ?? ""]))
|
|
@@ -2373,6 +2378,14 @@ function callsAlways(condition) {
|
|
|
2373
2378
|
* The default ids of junction jobs, shared by the checks that look for one.
|
|
2374
2379
|
*/
|
|
2375
2380
|
const DEFAULT_JUNCTION_JOBS = ["required-checks"];
|
|
2381
|
+
/**
|
|
2382
|
+
* The default ids of release jobs, shared by the checks that look for one.
|
|
2383
|
+
*/
|
|
2384
|
+
const DEFAULT_RELEASE_JOBS = ["release"];
|
|
2385
|
+
/**
|
|
2386
|
+
* The variables a token for the npm registry is passed in.
|
|
2387
|
+
*/
|
|
2388
|
+
const REGISTRY_TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
|
|
2376
2389
|
//#endregion
|
|
2377
2390
|
//#region src/checks/settings-required-checks.ts
|
|
2378
2391
|
/**
|
|
@@ -2523,10 +2536,6 @@ const PACKAGE_MANAGER_OPTIONS = String.raw`(?:\s+(?:(?:--filter|-F|--workspace|-
|
|
|
2523
2536
|
* Commands that publish a package to a registry.
|
|
2524
2537
|
*/
|
|
2525
2538
|
const PUBLISH_COMMAND = new RegExp(String.raw`^(?:(?:npm|pnpm|yarn)${PACKAGE_MANAGER_OPTIONS}(?:\s+npm)?\s+publish|(?:(?:pnpm|yarn)(?:\s+exec)?\s+|npx\s+)?(?:semantic-release|changeset\s+publish|lerna\s+publish))(?:\s|$)`, "u");
|
|
2526
|
-
/**
|
|
2527
|
-
* The variables a token for the registry is passed in.
|
|
2528
|
-
*/
|
|
2529
|
-
const TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
|
|
2530
2539
|
const PROVENANCE_FLAG = /--provenance(?:=true)?(?:\s|$)/u;
|
|
2531
2540
|
/**
|
|
2532
2541
|
* Whether the `package.json` in the working directory asks for provenance with `publishConfig.provenance: true`, which applies to every publish of that package.
|
|
@@ -2550,7 +2559,7 @@ function attestationViolations(workflow, job) {
|
|
|
2550
2559
|
* Whether the job passes a registry token to a step, which makes its publish not tokenless: a token variable with a value other than the empty string, anywhere in the environment.
|
|
2551
2560
|
*/
|
|
2552
2561
|
function passesToken(workflow, job) {
|
|
2553
|
-
return [void 0, ...job.steps].some((step) =>
|
|
2562
|
+
return [void 0, ...job.steps].some((step) => REGISTRY_TOKEN_VARIABLES.some((variable) => (effectiveEnv(workflow, job, step)[variable] ?? "") !== ""));
|
|
2554
2563
|
}
|
|
2555
2564
|
function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
|
|
2556
2565
|
if (accessOf(effectivePermissions(workflow, job), "id-token") !== "write" || passesToken(workflow, job)) return [];
|
|
@@ -2558,9 +2567,9 @@ function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
|
|
|
2558
2567
|
const publishes = scriptCommands(step.run).filter((line) => PUBLISH_COMMAND.test(line));
|
|
2559
2568
|
if (publishes.length === 0) return [];
|
|
2560
2569
|
const env = effectiveEnv(workflow, job, step);
|
|
2561
|
-
const blanked =
|
|
2570
|
+
const blanked = REGISTRY_TOKEN_VARIABLES.every((variable) => env[variable] === "");
|
|
2562
2571
|
const provenance = provenanceInManifest || publishes.some((line) => PROVENANCE_FLAG.test(line)) || env["NPM_CONFIG_PROVENANCE"] === "true";
|
|
2563
|
-
return blanked || provenance ? [] : [workflowViolation(workflow, "workflow-credentials/tokenless-publish-unprotected", `job '${job.id}' publishes with an OIDC identity and no token, but does not set ${
|
|
2572
|
+
return blanked || provenance ? [] : [workflowViolation(workflow, "workflow-credentials/tokenless-publish-unprotected", `job '${job.id}' publishes with an OIDC identity and no token, but does not set ${REGISTRY_TOKEN_VARIABLES.join(" and ")} to the empty string, so a token inherited from the environment is used when the OIDC exchange fails; set them to the empty string to prevent that, or request provenance so that such a publish at least carries a traceable attestation`, step.location)];
|
|
2564
2573
|
});
|
|
2565
2574
|
}
|
|
2566
2575
|
/**
|
|
@@ -2578,7 +2587,6 @@ const workflowCredentials = async ({ cwd, options }) => {
|
|
|
2578
2587
|
};
|
|
2579
2588
|
//#endregion
|
|
2580
2589
|
//#region src/checks/workflow-job-ordering.ts
|
|
2581
|
-
const DEFAULT_RELEASE_JOBS = ["release"];
|
|
2582
2590
|
const DEFAULT_DEPLOY_JOBS = ["deploy"];
|
|
2583
2591
|
const DEFAULT_DOCS_DEPLOY_JOBS = ["docs-deploy"];
|
|
2584
2592
|
const DEFAULT_BRANCH = "main";
|
|
@@ -2726,6 +2734,71 @@ const workflowMergeGroup = async ({ cwd, options }) => (await loadWorkflows(cwd,
|
|
|
2726
2734
|
return location === void 0 || workflow.triggers.has("merge_group") ? [] : [workflowViolation(workflow, "workflow-merge-group/missing-trigger", "the workflow runs for pull requests but has no merge_group trigger, so its checks do not report for a pull request in the merge queue", location)];
|
|
2727
2735
|
});
|
|
2728
2736
|
//#endregion
|
|
2737
|
+
//#region src/checks/workflow-release-job.ts
|
|
2738
|
+
const CHECKOUT_ACTION$1 = "actions/checkout";
|
|
2739
|
+
/**
|
|
2740
|
+
* A value that reads the `secrets` context inside an expression.
|
|
2741
|
+
*/
|
|
2742
|
+
const FROM_SECRET = /\$\{\{[^}]*\bsecrets\./u;
|
|
2743
|
+
/**
|
|
2744
|
+
* The release jobs named for a message: `release job 'a'` or `release jobs 'a' and 'b'`.
|
|
2745
|
+
*/
|
|
2746
|
+
function releaseJobsText(releases) {
|
|
2747
|
+
const ids = releases.map((job) => `'${job.id}'`);
|
|
2748
|
+
return `release job${ids.length === 1 ? "" : "s"} ${ids.join(" and ")}`;
|
|
2749
|
+
}
|
|
2750
|
+
function grantsIdToken(permissions) {
|
|
2751
|
+
return accessOf(permissions, "id-token") === "write";
|
|
2752
|
+
}
|
|
2753
|
+
function idTokenViolations(workflow, releases) {
|
|
2754
|
+
const releaseIds = new Set(releases.map((job) => job.id));
|
|
2755
|
+
const named = releaseJobsText(releases);
|
|
2756
|
+
const workflowLevel = grantsIdToken(workflow.permissions) && workflow.permissionsLocation !== void 0 ? [workflowViolation(workflow, "workflow-release-job/id-token-workflow-level", `the workflow's permissions grant id-token: write to every job that declares none of its own; grant it on ${named} only`, workflow.permissionsLocation)] : [];
|
|
2757
|
+
const jobLevel = workflow.jobs.filter((job) => !releaseIds.has(job.id) && grantsIdToken(job.permissions)).map((job) => workflowViolation(workflow, "workflow-release-job/id-token-outside-release", `job '${job.id}' is granted id-token: write but is not a release job, so it can mint the OIDC identity npm trusted publishing accepts for this workflow; only ${named} may hold it`, job.location));
|
|
2758
|
+
return [...workflowLevel, ...jobLevel];
|
|
2759
|
+
}
|
|
2760
|
+
/**
|
|
2761
|
+
* What a checkout that does not set `persist-credentials: false` leaves in git config.
|
|
2762
|
+
*/
|
|
2763
|
+
function persistedCredential(step) {
|
|
2764
|
+
if (step.with["ssh-key"] !== void 0) return "the SSH key";
|
|
2765
|
+
return step.with["token"] === void 0 ? "the job token" : "the token it was given";
|
|
2766
|
+
}
|
|
2767
|
+
function releaseJobViolations(workflow, job) {
|
|
2768
|
+
if (job.uses !== void 0) return [];
|
|
2769
|
+
const environment = job.environment === void 0 ? [workflowViolation(workflow, "workflow-release-job/missing-environment", `release job '${job.id}' declares no environment, so nothing outside the workflow file stops a branch's copy of it publishing; run it in a protected environment restricted to the default branch`, job.location)] : [];
|
|
2770
|
+
const checkouts = job.steps.filter((step) => stepUses(step, CHECKOUT_ACTION$1) && step.with["persist-credentials"] !== "false").map((step) => workflowViolation(workflow, "workflow-release-job/persisted-credentials", `release job '${job.id}' checks out without persist-credentials: false, which leaves ${persistedCredential(step)} in git config for every later step`, step.location));
|
|
2771
|
+
return [...environment, ...checkouts];
|
|
2772
|
+
}
|
|
2773
|
+
/**
|
|
2774
|
+
* The token variables an environment sets from a secret.
|
|
2775
|
+
*/
|
|
2776
|
+
function secretTokens(env) {
|
|
2777
|
+
return REGISTRY_TOKEN_VARIABLES.filter((variable) => FROM_SECRET.test(env[variable] ?? ""));
|
|
2778
|
+
}
|
|
2779
|
+
function tokenBesideOidcViolations(workflow, job) {
|
|
2780
|
+
if (!grantsIdToken(effectivePermissions(workflow, job))) return [];
|
|
2781
|
+
const report = (variables, location) => variables.length === 0 ? [] : [workflowViolation(workflow, "workflow-release-job/token-beside-oidc", `job '${job.id}' holds id-token: write and passes ${variables.join(" and ")} from a secret, a long-lived npm token beside the OIDC identity; publish through trusted publishing and blank the token variables`, location)];
|
|
2782
|
+
return [...report(secretTokens(effectiveEnv(workflow, job, void 0)), job.location), ...job.steps.flatMap((step) => report(secretTokens(step.env), step.location))];
|
|
2783
|
+
}
|
|
2784
|
+
/**
|
|
2785
|
+
* npm binds a trusted publisher to the repository and the workflow file, not to a branch, so anyone who can push a branch can edit that branch's copy of the workflow, drop an `if` that keeps publishing to the default branch, and publish. What stops that has to live outside the file: a protected environment that only the default branch may deploy to, named in npm's trusted publisher too. This check holds the release job to the shape that makes that protection hold.
|
|
2786
|
+
*
|
|
2787
|
+
* In a workflow with a release job (by id, `releaseJobs`, `release` by default): `workflow-release-job/id-token-workflow-level`, the workflow's own `permissions` grant `id-token: write` (directly or through `write-all`), which every job that declares none inherits; `id-token-outside-release`, a job that is not a release job declares `id-token: write` itself, a job that calls a reusable workflow included; `missing-environment`, a release job declares no `environment`; `persisted-credentials`, a release job runs `actions/checkout` without `persist-credentials: false` (literally; an expression is unknown and reported), so the job token, a `token` input or an `ssh-key` stays in git config for every later step. A release job that pushes as a deploy key provisions the key in a step and hands it to git through that step's `GIT_SSH_COMMAND`, after a checkout that persists nothing, and is accepted as written; semantic-release authenticates its own push (`repositoryUrl` as written, else the URL with `GITHUB_TOKEN` or `GH_TOKEN` from the environment), so it needs nothing from the checkout.
|
|
2788
|
+
*
|
|
2789
|
+
* In every workflow: `token-beside-oidc`, a job whose effective permissions grant `id-token: write` sets `NODE_AUTH_TOKEN` or `NPM_TOKEN` from the `secrets` context in its workflow, job or step `env`.
|
|
2790
|
+
*
|
|
2791
|
+
* Limits: the environment's protection rules and npm's trusted publisher settings are not visible in the file and are not read; a release job that calls a reusable workflow is judged only for `id-token`, since `environment` and steps belong to the called workflow; a token passed through `with`, written to an `.npmrc` by a script, or inherited through a composite action is not seen.
|
|
2792
|
+
*/
|
|
2793
|
+
const workflowReleaseJob = async ({ cwd, options }) => {
|
|
2794
|
+
return (await loadWorkflows(cwd, options)).flatMap((workflow) => {
|
|
2795
|
+
const releases = jobsNamed(workflow, options.releaseJobs ?? DEFAULT_RELEASE_JOBS);
|
|
2796
|
+
const releaseFindings = releases.length === 0 ? [] : idTokenViolations(workflow, releases);
|
|
2797
|
+
const jobFindings = workflow.jobs.flatMap((job) => [...releases.includes(job) ? releaseJobViolations(workflow, job) : [], ...tokenBesideOidcViolations(workflow, job)]);
|
|
2798
|
+
return [...releaseFindings, ...jobFindings];
|
|
2799
|
+
});
|
|
2800
|
+
};
|
|
2801
|
+
//#endregion
|
|
2729
2802
|
//#region src/checks/workflow-repository-dispatch.ts
|
|
2730
2803
|
const DEFAULT_BRANCHES = ["main", "master"];
|
|
2731
2804
|
const DEFAULT_BRANCH_EXPRESSION = /\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}/u;
|
|
@@ -3302,6 +3375,10 @@ const checks = zod.z.strictObject({
|
|
|
3302
3375
|
}))),
|
|
3303
3376
|
"workflow-version-single-source": zod.z.exactOptional(enabled(zod.z.strictObject(selection))),
|
|
3304
3377
|
"workflow-credentials": zod.z.exactOptional(enabled(zod.z.strictObject(selection))),
|
|
3378
|
+
"workflow-release-job": zod.z.exactOptional(enabled(zod.z.strictObject({
|
|
3379
|
+
...selection,
|
|
3380
|
+
releaseJobs: zod.z.exactOptional(names)
|
|
3381
|
+
}))),
|
|
3305
3382
|
"workflow-repository-dispatch": zod.z.exactOptional(enabled(zod.z.strictObject({
|
|
3306
3383
|
...selection,
|
|
3307
3384
|
defaultBranches: zod.z.exactOptional(names),
|
|
@@ -3595,6 +3672,12 @@ const registry = {
|
|
|
3595
3672
|
select: (checks) => enabledOptions(checks["workflow-credentials"]),
|
|
3596
3673
|
check: workflowCredentials
|
|
3597
3674
|
}),
|
|
3675
|
+
"workflow-release-job": plain({
|
|
3676
|
+
name: "workflow-release-job",
|
|
3677
|
+
description: "The release job alone holds id-token, runs in an environment and persists no credentials, and no job with id-token passes an npm token secret",
|
|
3678
|
+
select: (checks) => enabledOptions(checks["workflow-release-job"]),
|
|
3679
|
+
check: workflowReleaseJob
|
|
3680
|
+
}),
|
|
3598
3681
|
"workflow-repository-dispatch": plain({
|
|
3599
3682
|
name: "workflow-repository-dispatch",
|
|
3600
3683
|
description: "A repository_dispatch handler does not push to the default branch, auto-merge unguarded, or rely on the default token",
|
|
@@ -3902,6 +3985,7 @@ exports.workflowActionPinning = workflowActionPinning;
|
|
|
3902
3985
|
exports.workflowCredentials = workflowCredentials;
|
|
3903
3986
|
exports.workflowJobOrdering = workflowJobOrdering;
|
|
3904
3987
|
exports.workflowMergeGroup = workflowMergeGroup;
|
|
3988
|
+
exports.workflowReleaseJob = workflowReleaseJob;
|
|
3905
3989
|
exports.workflowRepositoryDispatch = workflowRepositoryDispatch;
|
|
3906
3990
|
exports.workflowRunnerResolution = workflowRunnerResolution;
|
|
3907
3991
|
exports.workflowSkippableJobs = workflowSkippableJobs;
|
package/dist/index.d.cts
CHANGED
|
@@ -255,6 +255,10 @@ interface Job {
|
|
|
255
255
|
readonly needs: readonly string[];
|
|
256
256
|
readonly condition: string | undefined;
|
|
257
257
|
readonly permissions: Permissions;
|
|
258
|
+
/**
|
|
259
|
+
* The name of the deployment environment the job runs in, written as `environment: <name>` or `environment: { name: <name> }`; `undefined` when it names none.
|
|
260
|
+
*/
|
|
261
|
+
readonly environment: string | undefined;
|
|
258
262
|
readonly env: Readonly<Record<string, string>>;
|
|
259
263
|
readonly runsOn: RunsOn;
|
|
260
264
|
readonly timeoutMinutes: string | undefined;
|
|
@@ -282,6 +286,10 @@ interface Workflow {
|
|
|
282
286
|
*/
|
|
283
287
|
readonly triggerLocations: ReadonlyMap<string, SourceLocation>;
|
|
284
288
|
readonly permissions: Permissions;
|
|
289
|
+
/**
|
|
290
|
+
* Where the workflow's `permissions` is written; `undefined` when it declares none.
|
|
291
|
+
*/
|
|
292
|
+
readonly permissionsLocation: SourceLocation | undefined;
|
|
285
293
|
readonly env: Readonly<Record<string, string>>;
|
|
286
294
|
readonly jobs: readonly Job[];
|
|
287
295
|
/**
|
|
@@ -667,6 +675,15 @@ type WorkflowVersionSingleSourceOptions = WorkflowSelection;
|
|
|
667
675
|
* Options of the `workflow-credentials` check.
|
|
668
676
|
*/
|
|
669
677
|
type WorkflowCredentialsOptions = WorkflowSelection;
|
|
678
|
+
/**
|
|
679
|
+
* Options of the `workflow-release-job` check: the job that publishes is the only one that can mint the OIDC identity, runs in an environment and leaves no credentials behind.
|
|
680
|
+
*/
|
|
681
|
+
interface WorkflowReleaseJobOptions extends WorkflowSelection {
|
|
682
|
+
/**
|
|
683
|
+
* Ids of release jobs: the jobs that publish, and the only ones that may hold `id-token: write`. `release` when omitted.
|
|
684
|
+
*/
|
|
685
|
+
readonly releaseJobs?: readonly string[];
|
|
686
|
+
}
|
|
670
687
|
/**
|
|
671
688
|
* Options of the `workflow-repository-dispatch` check: what a handler of `repository_dispatch` may do.
|
|
672
689
|
*/
|
|
@@ -781,6 +798,7 @@ interface CheckOptionsByName {
|
|
|
781
798
|
readonly "workflow-runner-resolution": WorkflowRunnerResolutionOptions;
|
|
782
799
|
readonly "workflow-version-single-source": WorkflowVersionSingleSourceOptions;
|
|
783
800
|
readonly "workflow-credentials": WorkflowCredentialsOptions;
|
|
801
|
+
readonly "workflow-release-job": WorkflowReleaseJobOptions;
|
|
784
802
|
readonly "workflow-repository-dispatch": WorkflowRepositoryDispatchOptions;
|
|
785
803
|
readonly "workflow-update-bot-cooldown": WorkflowUpdateBotCooldownOptions;
|
|
786
804
|
readonly "workflow-merge-group": WorkflowMergeGroupOptions;
|
|
@@ -1088,6 +1106,18 @@ export declare const workflowJobOrdering: CheckFunction<WorkflowJobOrderingOptio
|
|
|
1088
1106
|
*/
|
|
1089
1107
|
export declare const workflowMergeGroup: CheckFunction<WorkflowMergeGroupOptions>;
|
|
1090
1108
|
//#endregion
|
|
1109
|
+
//#region src/checks/workflow-release-job.d.ts
|
|
1110
|
+
/**
|
|
1111
|
+
* npm binds a trusted publisher to the repository and the workflow file, not to a branch, so anyone who can push a branch can edit that branch's copy of the workflow, drop an `if` that keeps publishing to the default branch, and publish. What stops that has to live outside the file: a protected environment that only the default branch may deploy to, named in npm's trusted publisher too. This check holds the release job to the shape that makes that protection hold.
|
|
1112
|
+
*
|
|
1113
|
+
* In a workflow with a release job (by id, `releaseJobs`, `release` by default): `workflow-release-job/id-token-workflow-level`, the workflow's own `permissions` grant `id-token: write` (directly or through `write-all`), which every job that declares none inherits; `id-token-outside-release`, a job that is not a release job declares `id-token: write` itself, a job that calls a reusable workflow included; `missing-environment`, a release job declares no `environment`; `persisted-credentials`, a release job runs `actions/checkout` without `persist-credentials: false` (literally; an expression is unknown and reported), so the job token, a `token` input or an `ssh-key` stays in git config for every later step. A release job that pushes as a deploy key provisions the key in a step and hands it to git through that step's `GIT_SSH_COMMAND`, after a checkout that persists nothing, and is accepted as written; semantic-release authenticates its own push (`repositoryUrl` as written, else the URL with `GITHUB_TOKEN` or `GH_TOKEN` from the environment), so it needs nothing from the checkout.
|
|
1114
|
+
*
|
|
1115
|
+
* In every workflow: `token-beside-oidc`, a job whose effective permissions grant `id-token: write` sets `NODE_AUTH_TOKEN` or `NPM_TOKEN` from the `secrets` context in its workflow, job or step `env`.
|
|
1116
|
+
*
|
|
1117
|
+
* Limits: the environment's protection rules and npm's trusted publisher settings are not visible in the file and are not read; a release job that calls a reusable workflow is judged only for `id-token`, since `environment` and steps belong to the called workflow; a token passed through `with`, written to an `.npmrc` by a script, or inherited through a composite action is not seen.
|
|
1118
|
+
*/
|
|
1119
|
+
export declare const workflowReleaseJob: CheckFunction<WorkflowReleaseJobOptions>;
|
|
1120
|
+
//#endregion
|
|
1091
1121
|
//#region src/checks/workflow-repository-dispatch.d.ts
|
|
1092
1122
|
/**
|
|
1093
1123
|
* A workflow that handles `repository_dispatch` can be started by anyone with write access through the API, so what it does with the repository is held to a higher bar.
|
|
@@ -1356,4 +1386,4 @@ export declare function proveExhaustive(input: {
|
|
|
1356
1386
|
readonly compilerOptions?: CompilerOptions;
|
|
1357
1387
|
}): ExhaustivenessProof;
|
|
1358
1388
|
//#endregion
|
|
1359
|
-
export type { AggregateMappersOptions, BranchRules, CaseRemoval, CheckContext, CheckFunction, CheckName, CheckOptionsByName, CheckResult, ChecksConfig, CodecPairsOptions, CommandTypesOptions, CommitTypesOptions, ConformanceConfig, DeployDirectory, DeploySelector, DeployToolAdapter, DeployToolName, DerivedTypesOptions, DerivedTypesPair, DiscoveredPackage, DockerfilePackageManagerOptions, EslintOptions, EslintRequiredSeverity, EslintSample, ExhaustivenessProof, GeneratorAdapter, GeneratorName, GitHubCheckContext, GitHubCheckFunction, GitHubClient, GitHubClientOptions, ImportGraphOptions, InstructionSymlinksOptions, Job, LayoutCheckContext, LayoutCheckFunction, MigrationsDirectoryOptions, PermissionAccess, Permissions, PinningLevel, RegisteredCheck, RepositorySettings, RepositorySlug, RunChecksOptions, RunResult, RunsOn, SettingsMergeMethodsOptions, SettingsOptions, SettingsRequiredChecksOptions, SettingsReviewThreadResolutionOptions, SingleStorybookOptions, SourceLocation, Step, TypeErrorReport, UsesReference, Violation, Workflow, WorkflowActionPinningOptions, WorkflowCredentialsOptions, WorkflowJobOrderingOptions, WorkflowMergeGroupOptions, WorkflowRepositoryDispatchOptions, WorkflowRunnerResolutionOptions, WorkflowSelection, WorkflowSkippableJobsOptions, WorkflowUpdateBotCooldownOptions, WorkflowVersionSingleSourceOptions, WorkspacePackage };
|
|
1389
|
+
export type { AggregateMappersOptions, BranchRules, CaseRemoval, CheckContext, CheckFunction, CheckName, CheckOptionsByName, CheckResult, ChecksConfig, CodecPairsOptions, CommandTypesOptions, CommitTypesOptions, ConformanceConfig, DeployDirectory, DeploySelector, DeployToolAdapter, DeployToolName, DerivedTypesOptions, DerivedTypesPair, DiscoveredPackage, DockerfilePackageManagerOptions, EslintOptions, EslintRequiredSeverity, EslintSample, ExhaustivenessProof, GeneratorAdapter, GeneratorName, GitHubCheckContext, GitHubCheckFunction, GitHubClient, GitHubClientOptions, ImportGraphOptions, InstructionSymlinksOptions, Job, LayoutCheckContext, LayoutCheckFunction, MigrationsDirectoryOptions, PermissionAccess, Permissions, PinningLevel, RegisteredCheck, RepositorySettings, RepositorySlug, RunChecksOptions, RunResult, RunsOn, SettingsMergeMethodsOptions, SettingsOptions, SettingsRequiredChecksOptions, SettingsReviewThreadResolutionOptions, SingleStorybookOptions, SourceLocation, Step, TypeErrorReport, UsesReference, Violation, Workflow, WorkflowActionPinningOptions, WorkflowCredentialsOptions, WorkflowJobOrderingOptions, WorkflowMergeGroupOptions, WorkflowReleaseJobOptions, WorkflowRepositoryDispatchOptions, WorkflowRunnerResolutionOptions, WorkflowSelection, WorkflowSkippableJobsOptions, WorkflowUpdateBotCooldownOptions, WorkflowVersionSingleSourceOptions, WorkspacePackage };
|
package/dist/index.d.ts
CHANGED
|
@@ -255,6 +255,10 @@ interface Job {
|
|
|
255
255
|
readonly needs: readonly string[];
|
|
256
256
|
readonly condition: string | undefined;
|
|
257
257
|
readonly permissions: Permissions;
|
|
258
|
+
/**
|
|
259
|
+
* The name of the deployment environment the job runs in, written as `environment: <name>` or `environment: { name: <name> }`; `undefined` when it names none.
|
|
260
|
+
*/
|
|
261
|
+
readonly environment: string | undefined;
|
|
258
262
|
readonly env: Readonly<Record<string, string>>;
|
|
259
263
|
readonly runsOn: RunsOn;
|
|
260
264
|
readonly timeoutMinutes: string | undefined;
|
|
@@ -282,6 +286,10 @@ interface Workflow {
|
|
|
282
286
|
*/
|
|
283
287
|
readonly triggerLocations: ReadonlyMap<string, SourceLocation>;
|
|
284
288
|
readonly permissions: Permissions;
|
|
289
|
+
/**
|
|
290
|
+
* Where the workflow's `permissions` is written; `undefined` when it declares none.
|
|
291
|
+
*/
|
|
292
|
+
readonly permissionsLocation: SourceLocation | undefined;
|
|
285
293
|
readonly env: Readonly<Record<string, string>>;
|
|
286
294
|
readonly jobs: readonly Job[];
|
|
287
295
|
/**
|
|
@@ -667,6 +675,15 @@ type WorkflowVersionSingleSourceOptions = WorkflowSelection;
|
|
|
667
675
|
* Options of the `workflow-credentials` check.
|
|
668
676
|
*/
|
|
669
677
|
type WorkflowCredentialsOptions = WorkflowSelection;
|
|
678
|
+
/**
|
|
679
|
+
* Options of the `workflow-release-job` check: the job that publishes is the only one that can mint the OIDC identity, runs in an environment and leaves no credentials behind.
|
|
680
|
+
*/
|
|
681
|
+
interface WorkflowReleaseJobOptions extends WorkflowSelection {
|
|
682
|
+
/**
|
|
683
|
+
* Ids of release jobs: the jobs that publish, and the only ones that may hold `id-token: write`. `release` when omitted.
|
|
684
|
+
*/
|
|
685
|
+
readonly releaseJobs?: readonly string[];
|
|
686
|
+
}
|
|
670
687
|
/**
|
|
671
688
|
* Options of the `workflow-repository-dispatch` check: what a handler of `repository_dispatch` may do.
|
|
672
689
|
*/
|
|
@@ -781,6 +798,7 @@ interface CheckOptionsByName {
|
|
|
781
798
|
readonly "workflow-runner-resolution": WorkflowRunnerResolutionOptions;
|
|
782
799
|
readonly "workflow-version-single-source": WorkflowVersionSingleSourceOptions;
|
|
783
800
|
readonly "workflow-credentials": WorkflowCredentialsOptions;
|
|
801
|
+
readonly "workflow-release-job": WorkflowReleaseJobOptions;
|
|
784
802
|
readonly "workflow-repository-dispatch": WorkflowRepositoryDispatchOptions;
|
|
785
803
|
readonly "workflow-update-bot-cooldown": WorkflowUpdateBotCooldownOptions;
|
|
786
804
|
readonly "workflow-merge-group": WorkflowMergeGroupOptions;
|
|
@@ -1088,6 +1106,18 @@ export declare const workflowJobOrdering: CheckFunction<WorkflowJobOrderingOptio
|
|
|
1088
1106
|
*/
|
|
1089
1107
|
export declare const workflowMergeGroup: CheckFunction<WorkflowMergeGroupOptions>;
|
|
1090
1108
|
//#endregion
|
|
1109
|
+
//#region src/checks/workflow-release-job.d.ts
|
|
1110
|
+
/**
|
|
1111
|
+
* npm binds a trusted publisher to the repository and the workflow file, not to a branch, so anyone who can push a branch can edit that branch's copy of the workflow, drop an `if` that keeps publishing to the default branch, and publish. What stops that has to live outside the file: a protected environment that only the default branch may deploy to, named in npm's trusted publisher too. This check holds the release job to the shape that makes that protection hold.
|
|
1112
|
+
*
|
|
1113
|
+
* In a workflow with a release job (by id, `releaseJobs`, `release` by default): `workflow-release-job/id-token-workflow-level`, the workflow's own `permissions` grant `id-token: write` (directly or through `write-all`), which every job that declares none inherits; `id-token-outside-release`, a job that is not a release job declares `id-token: write` itself, a job that calls a reusable workflow included; `missing-environment`, a release job declares no `environment`; `persisted-credentials`, a release job runs `actions/checkout` without `persist-credentials: false` (literally; an expression is unknown and reported), so the job token, a `token` input or an `ssh-key` stays in git config for every later step. A release job that pushes as a deploy key provisions the key in a step and hands it to git through that step's `GIT_SSH_COMMAND`, after a checkout that persists nothing, and is accepted as written; semantic-release authenticates its own push (`repositoryUrl` as written, else the URL with `GITHUB_TOKEN` or `GH_TOKEN` from the environment), so it needs nothing from the checkout.
|
|
1114
|
+
*
|
|
1115
|
+
* In every workflow: `token-beside-oidc`, a job whose effective permissions grant `id-token: write` sets `NODE_AUTH_TOKEN` or `NPM_TOKEN` from the `secrets` context in its workflow, job or step `env`.
|
|
1116
|
+
*
|
|
1117
|
+
* Limits: the environment's protection rules and npm's trusted publisher settings are not visible in the file and are not read; a release job that calls a reusable workflow is judged only for `id-token`, since `environment` and steps belong to the called workflow; a token passed through `with`, written to an `.npmrc` by a script, or inherited through a composite action is not seen.
|
|
1118
|
+
*/
|
|
1119
|
+
export declare const workflowReleaseJob: CheckFunction<WorkflowReleaseJobOptions>;
|
|
1120
|
+
//#endregion
|
|
1091
1121
|
//#region src/checks/workflow-repository-dispatch.d.ts
|
|
1092
1122
|
/**
|
|
1093
1123
|
* A workflow that handles `repository_dispatch` can be started by anyone with write access through the API, so what it does with the repository is held to a higher bar.
|
|
@@ -1356,4 +1386,4 @@ export declare function proveExhaustive(input: {
|
|
|
1356
1386
|
readonly compilerOptions?: CompilerOptions;
|
|
1357
1387
|
}): ExhaustivenessProof;
|
|
1358
1388
|
//#endregion
|
|
1359
|
-
export type { AggregateMappersOptions, BranchRules, CaseRemoval, CheckContext, CheckFunction, CheckName, CheckOptionsByName, CheckResult, ChecksConfig, CodecPairsOptions, CommandTypesOptions, CommitTypesOptions, ConformanceConfig, DeployDirectory, DeploySelector, DeployToolAdapter, DeployToolName, DerivedTypesOptions, DerivedTypesPair, DiscoveredPackage, DockerfilePackageManagerOptions, EslintOptions, EslintRequiredSeverity, EslintSample, ExhaustivenessProof, GeneratorAdapter, GeneratorName, GitHubCheckContext, GitHubCheckFunction, GitHubClient, GitHubClientOptions, ImportGraphOptions, InstructionSymlinksOptions, Job, LayoutCheckContext, LayoutCheckFunction, MigrationsDirectoryOptions, PermissionAccess, Permissions, PinningLevel, RegisteredCheck, RepositorySettings, RepositorySlug, RunChecksOptions, RunResult, RunsOn, SettingsMergeMethodsOptions, SettingsOptions, SettingsRequiredChecksOptions, SettingsReviewThreadResolutionOptions, SingleStorybookOptions, SourceLocation, Step, TypeErrorReport, UsesReference, Violation, Workflow, WorkflowActionPinningOptions, WorkflowCredentialsOptions, WorkflowJobOrderingOptions, WorkflowMergeGroupOptions, WorkflowRepositoryDispatchOptions, WorkflowRunnerResolutionOptions, WorkflowSelection, WorkflowSkippableJobsOptions, WorkflowUpdateBotCooldownOptions, WorkflowVersionSingleSourceOptions, WorkspacePackage };
|
|
1389
|
+
export type { AggregateMappersOptions, BranchRules, CaseRemoval, CheckContext, CheckFunction, CheckName, CheckOptionsByName, CheckResult, ChecksConfig, CodecPairsOptions, CommandTypesOptions, CommitTypesOptions, ConformanceConfig, DeployDirectory, DeploySelector, DeployToolAdapter, DeployToolName, DerivedTypesOptions, DerivedTypesPair, DiscoveredPackage, DockerfilePackageManagerOptions, EslintOptions, EslintRequiredSeverity, EslintSample, ExhaustivenessProof, GeneratorAdapter, GeneratorName, GitHubCheckContext, GitHubCheckFunction, GitHubClient, GitHubClientOptions, ImportGraphOptions, InstructionSymlinksOptions, Job, LayoutCheckContext, LayoutCheckFunction, MigrationsDirectoryOptions, PermissionAccess, Permissions, PinningLevel, RegisteredCheck, RepositorySettings, RepositorySlug, RunChecksOptions, RunResult, RunsOn, SettingsMergeMethodsOptions, SettingsOptions, SettingsRequiredChecksOptions, SettingsReviewThreadResolutionOptions, SingleStorybookOptions, SourceLocation, Step, TypeErrorReport, UsesReference, Violation, Workflow, WorkflowActionPinningOptions, WorkflowCredentialsOptions, WorkflowJobOrderingOptions, WorkflowMergeGroupOptions, WorkflowReleaseJobOptions, WorkflowRepositoryDispatchOptions, WorkflowRunnerResolutionOptions, WorkflowSelection, WorkflowSkippableJobsOptions, WorkflowUpdateBotCooldownOptions, WorkflowVersionSingleSourceOptions, WorkspacePackage };
|
package/dist/index.js
CHANGED
|
@@ -2127,6 +2127,9 @@ function runsOnOf(reader, value, what) {
|
|
|
2127
2127
|
group
|
|
2128
2128
|
};
|
|
2129
2129
|
}
|
|
2130
|
+
function environmentOf(reader, value, what) {
|
|
2131
|
+
return isRecord(value) ? reader.text(value["name"], `${what}.name`) : reader.text(value, what);
|
|
2132
|
+
}
|
|
2130
2133
|
function stepOf(reader, value, jobPath, index) {
|
|
2131
2134
|
const path = [
|
|
2132
2135
|
...jobPath,
|
|
@@ -2159,6 +2162,7 @@ function jobOf(reader, id, value) {
|
|
|
2159
2162
|
needs: reader.list(job["needs"], `${what}.needs`),
|
|
2160
2163
|
condition: reader.text(job["if"], `${what}.if`),
|
|
2161
2164
|
permissions: permissionsOf(reader, job["permissions"], `${what}.permissions`),
|
|
2165
|
+
environment: environmentOf(reader, job["environment"], `${what}.environment`),
|
|
2162
2166
|
env: reader.texts(job["env"], `${what}.env`),
|
|
2163
2167
|
runsOn: runsOnOf(reader, job["runs-on"], `${what}.runs-on`),
|
|
2164
2168
|
timeoutMinutes: reader.text(job["timeout-minutes"], `${what}.timeout-minutes`),
|
|
@@ -2194,6 +2198,7 @@ function parseWorkflow(file, source) {
|
|
|
2194
2198
|
triggers,
|
|
2195
2199
|
triggerLocations: triggerLocationsOf(reader, triggers, root["on"]),
|
|
2196
2200
|
permissions: permissionsOf(reader, root["permissions"], "permissions"),
|
|
2201
|
+
permissionsLocation: root["permissions"] === void 0 ? void 0 : reader.location(["permissions"]),
|
|
2197
2202
|
env: reader.texts(root["env"], "env"),
|
|
2198
2203
|
jobs: Object.entries(reader.record(root["jobs"], "jobs")).map(([id, job]) => jobOf(reader, id, job)),
|
|
2199
2204
|
callOutputs: Object.fromEntries(Object.entries(outputs).map(([name, output]) => [name, reader.text(reader.record(output, `on.workflow_call.outputs.${name}`)["value"], `on.workflow_call.outputs.${name}.value`) ?? ""]))
|
|
@@ -2372,6 +2377,14 @@ function callsAlways(condition) {
|
|
|
2372
2377
|
* The default ids of junction jobs, shared by the checks that look for one.
|
|
2373
2378
|
*/
|
|
2374
2379
|
const DEFAULT_JUNCTION_JOBS = ["required-checks"];
|
|
2380
|
+
/**
|
|
2381
|
+
* The default ids of release jobs, shared by the checks that look for one.
|
|
2382
|
+
*/
|
|
2383
|
+
const DEFAULT_RELEASE_JOBS = ["release"];
|
|
2384
|
+
/**
|
|
2385
|
+
* The variables a token for the npm registry is passed in.
|
|
2386
|
+
*/
|
|
2387
|
+
const REGISTRY_TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
|
|
2375
2388
|
//#endregion
|
|
2376
2389
|
//#region src/checks/settings-required-checks.ts
|
|
2377
2390
|
/**
|
|
@@ -2522,10 +2535,6 @@ const PACKAGE_MANAGER_OPTIONS = String.raw`(?:\s+(?:(?:--filter|-F|--workspace|-
|
|
|
2522
2535
|
* Commands that publish a package to a registry.
|
|
2523
2536
|
*/
|
|
2524
2537
|
const PUBLISH_COMMAND = new RegExp(String.raw`^(?:(?:npm|pnpm|yarn)${PACKAGE_MANAGER_OPTIONS}(?:\s+npm)?\s+publish|(?:(?:pnpm|yarn)(?:\s+exec)?\s+|npx\s+)?(?:semantic-release|changeset\s+publish|lerna\s+publish))(?:\s|$)`, "u");
|
|
2525
|
-
/**
|
|
2526
|
-
* The variables a token for the registry is passed in.
|
|
2527
|
-
*/
|
|
2528
|
-
const TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
|
|
2529
2538
|
const PROVENANCE_FLAG = /--provenance(?:=true)?(?:\s|$)/u;
|
|
2530
2539
|
/**
|
|
2531
2540
|
* Whether the `package.json` in the working directory asks for provenance with `publishConfig.provenance: true`, which applies to every publish of that package.
|
|
@@ -2549,7 +2558,7 @@ function attestationViolations(workflow, job) {
|
|
|
2549
2558
|
* Whether the job passes a registry token to a step, which makes its publish not tokenless: a token variable with a value other than the empty string, anywhere in the environment.
|
|
2550
2559
|
*/
|
|
2551
2560
|
function passesToken(workflow, job) {
|
|
2552
|
-
return [void 0, ...job.steps].some((step) =>
|
|
2561
|
+
return [void 0, ...job.steps].some((step) => REGISTRY_TOKEN_VARIABLES.some((variable) => (effectiveEnv(workflow, job, step)[variable] ?? "") !== ""));
|
|
2553
2562
|
}
|
|
2554
2563
|
function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
|
|
2555
2564
|
if (accessOf(effectivePermissions(workflow, job), "id-token") !== "write" || passesToken(workflow, job)) return [];
|
|
@@ -2557,9 +2566,9 @@ function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
|
|
|
2557
2566
|
const publishes = scriptCommands(step.run).filter((line) => PUBLISH_COMMAND.test(line));
|
|
2558
2567
|
if (publishes.length === 0) return [];
|
|
2559
2568
|
const env = effectiveEnv(workflow, job, step);
|
|
2560
|
-
const blanked =
|
|
2569
|
+
const blanked = REGISTRY_TOKEN_VARIABLES.every((variable) => env[variable] === "");
|
|
2561
2570
|
const provenance = provenanceInManifest || publishes.some((line) => PROVENANCE_FLAG.test(line)) || env["NPM_CONFIG_PROVENANCE"] === "true";
|
|
2562
|
-
return blanked || provenance ? [] : [workflowViolation(workflow, "workflow-credentials/tokenless-publish-unprotected", `job '${job.id}' publishes with an OIDC identity and no token, but does not set ${
|
|
2571
|
+
return blanked || provenance ? [] : [workflowViolation(workflow, "workflow-credentials/tokenless-publish-unprotected", `job '${job.id}' publishes with an OIDC identity and no token, but does not set ${REGISTRY_TOKEN_VARIABLES.join(" and ")} to the empty string, so a token inherited from the environment is used when the OIDC exchange fails; set them to the empty string to prevent that, or request provenance so that such a publish at least carries a traceable attestation`, step.location)];
|
|
2563
2572
|
});
|
|
2564
2573
|
}
|
|
2565
2574
|
/**
|
|
@@ -2577,7 +2586,6 @@ const workflowCredentials = async ({ cwd, options }) => {
|
|
|
2577
2586
|
};
|
|
2578
2587
|
//#endregion
|
|
2579
2588
|
//#region src/checks/workflow-job-ordering.ts
|
|
2580
|
-
const DEFAULT_RELEASE_JOBS = ["release"];
|
|
2581
2589
|
const DEFAULT_DEPLOY_JOBS = ["deploy"];
|
|
2582
2590
|
const DEFAULT_DOCS_DEPLOY_JOBS = ["docs-deploy"];
|
|
2583
2591
|
const DEFAULT_BRANCH = "main";
|
|
@@ -2725,6 +2733,71 @@ const workflowMergeGroup = async ({ cwd, options }) => (await loadWorkflows(cwd,
|
|
|
2725
2733
|
return location === void 0 || workflow.triggers.has("merge_group") ? [] : [workflowViolation(workflow, "workflow-merge-group/missing-trigger", "the workflow runs for pull requests but has no merge_group trigger, so its checks do not report for a pull request in the merge queue", location)];
|
|
2726
2734
|
});
|
|
2727
2735
|
//#endregion
|
|
2736
|
+
//#region src/checks/workflow-release-job.ts
|
|
2737
|
+
const CHECKOUT_ACTION$1 = "actions/checkout";
|
|
2738
|
+
/**
|
|
2739
|
+
* A value that reads the `secrets` context inside an expression.
|
|
2740
|
+
*/
|
|
2741
|
+
const FROM_SECRET = /\$\{\{[^}]*\bsecrets\./u;
|
|
2742
|
+
/**
|
|
2743
|
+
* The release jobs named for a message: `release job 'a'` or `release jobs 'a' and 'b'`.
|
|
2744
|
+
*/
|
|
2745
|
+
function releaseJobsText(releases) {
|
|
2746
|
+
const ids = releases.map((job) => `'${job.id}'`);
|
|
2747
|
+
return `release job${ids.length === 1 ? "" : "s"} ${ids.join(" and ")}`;
|
|
2748
|
+
}
|
|
2749
|
+
function grantsIdToken(permissions) {
|
|
2750
|
+
return accessOf(permissions, "id-token") === "write";
|
|
2751
|
+
}
|
|
2752
|
+
function idTokenViolations(workflow, releases) {
|
|
2753
|
+
const releaseIds = new Set(releases.map((job) => job.id));
|
|
2754
|
+
const named = releaseJobsText(releases);
|
|
2755
|
+
const workflowLevel = grantsIdToken(workflow.permissions) && workflow.permissionsLocation !== void 0 ? [workflowViolation(workflow, "workflow-release-job/id-token-workflow-level", `the workflow's permissions grant id-token: write to every job that declares none of its own; grant it on ${named} only`, workflow.permissionsLocation)] : [];
|
|
2756
|
+
const jobLevel = workflow.jobs.filter((job) => !releaseIds.has(job.id) && grantsIdToken(job.permissions)).map((job) => workflowViolation(workflow, "workflow-release-job/id-token-outside-release", `job '${job.id}' is granted id-token: write but is not a release job, so it can mint the OIDC identity npm trusted publishing accepts for this workflow; only ${named} may hold it`, job.location));
|
|
2757
|
+
return [...workflowLevel, ...jobLevel];
|
|
2758
|
+
}
|
|
2759
|
+
/**
|
|
2760
|
+
* What a checkout that does not set `persist-credentials: false` leaves in git config.
|
|
2761
|
+
*/
|
|
2762
|
+
function persistedCredential(step) {
|
|
2763
|
+
if (step.with["ssh-key"] !== void 0) return "the SSH key";
|
|
2764
|
+
return step.with["token"] === void 0 ? "the job token" : "the token it was given";
|
|
2765
|
+
}
|
|
2766
|
+
function releaseJobViolations(workflow, job) {
|
|
2767
|
+
if (job.uses !== void 0) return [];
|
|
2768
|
+
const environment = job.environment === void 0 ? [workflowViolation(workflow, "workflow-release-job/missing-environment", `release job '${job.id}' declares no environment, so nothing outside the workflow file stops a branch's copy of it publishing; run it in a protected environment restricted to the default branch`, job.location)] : [];
|
|
2769
|
+
const checkouts = job.steps.filter((step) => stepUses(step, CHECKOUT_ACTION$1) && step.with["persist-credentials"] !== "false").map((step) => workflowViolation(workflow, "workflow-release-job/persisted-credentials", `release job '${job.id}' checks out without persist-credentials: false, which leaves ${persistedCredential(step)} in git config for every later step`, step.location));
|
|
2770
|
+
return [...environment, ...checkouts];
|
|
2771
|
+
}
|
|
2772
|
+
/**
|
|
2773
|
+
* The token variables an environment sets from a secret.
|
|
2774
|
+
*/
|
|
2775
|
+
function secretTokens(env) {
|
|
2776
|
+
return REGISTRY_TOKEN_VARIABLES.filter((variable) => FROM_SECRET.test(env[variable] ?? ""));
|
|
2777
|
+
}
|
|
2778
|
+
function tokenBesideOidcViolations(workflow, job) {
|
|
2779
|
+
if (!grantsIdToken(effectivePermissions(workflow, job))) return [];
|
|
2780
|
+
const report = (variables, location) => variables.length === 0 ? [] : [workflowViolation(workflow, "workflow-release-job/token-beside-oidc", `job '${job.id}' holds id-token: write and passes ${variables.join(" and ")} from a secret, a long-lived npm token beside the OIDC identity; publish through trusted publishing and blank the token variables`, location)];
|
|
2781
|
+
return [...report(secretTokens(effectiveEnv(workflow, job, void 0)), job.location), ...job.steps.flatMap((step) => report(secretTokens(step.env), step.location))];
|
|
2782
|
+
}
|
|
2783
|
+
/**
|
|
2784
|
+
* npm binds a trusted publisher to the repository and the workflow file, not to a branch, so anyone who can push a branch can edit that branch's copy of the workflow, drop an `if` that keeps publishing to the default branch, and publish. What stops that has to live outside the file: a protected environment that only the default branch may deploy to, named in npm's trusted publisher too. This check holds the release job to the shape that makes that protection hold.
|
|
2785
|
+
*
|
|
2786
|
+
* In a workflow with a release job (by id, `releaseJobs`, `release` by default): `workflow-release-job/id-token-workflow-level`, the workflow's own `permissions` grant `id-token: write` (directly or through `write-all`), which every job that declares none inherits; `id-token-outside-release`, a job that is not a release job declares `id-token: write` itself, a job that calls a reusable workflow included; `missing-environment`, a release job declares no `environment`; `persisted-credentials`, a release job runs `actions/checkout` without `persist-credentials: false` (literally; an expression is unknown and reported), so the job token, a `token` input or an `ssh-key` stays in git config for every later step. A release job that pushes as a deploy key provisions the key in a step and hands it to git through that step's `GIT_SSH_COMMAND`, after a checkout that persists nothing, and is accepted as written; semantic-release authenticates its own push (`repositoryUrl` as written, else the URL with `GITHUB_TOKEN` or `GH_TOKEN` from the environment), so it needs nothing from the checkout.
|
|
2787
|
+
*
|
|
2788
|
+
* In every workflow: `token-beside-oidc`, a job whose effective permissions grant `id-token: write` sets `NODE_AUTH_TOKEN` or `NPM_TOKEN` from the `secrets` context in its workflow, job or step `env`.
|
|
2789
|
+
*
|
|
2790
|
+
* Limits: the environment's protection rules and npm's trusted publisher settings are not visible in the file and are not read; a release job that calls a reusable workflow is judged only for `id-token`, since `environment` and steps belong to the called workflow; a token passed through `with`, written to an `.npmrc` by a script, or inherited through a composite action is not seen.
|
|
2791
|
+
*/
|
|
2792
|
+
const workflowReleaseJob = async ({ cwd, options }) => {
|
|
2793
|
+
return (await loadWorkflows(cwd, options)).flatMap((workflow) => {
|
|
2794
|
+
const releases = jobsNamed(workflow, options.releaseJobs ?? DEFAULT_RELEASE_JOBS);
|
|
2795
|
+
const releaseFindings = releases.length === 0 ? [] : idTokenViolations(workflow, releases);
|
|
2796
|
+
const jobFindings = workflow.jobs.flatMap((job) => [...releases.includes(job) ? releaseJobViolations(workflow, job) : [], ...tokenBesideOidcViolations(workflow, job)]);
|
|
2797
|
+
return [...releaseFindings, ...jobFindings];
|
|
2798
|
+
});
|
|
2799
|
+
};
|
|
2800
|
+
//#endregion
|
|
2728
2801
|
//#region src/checks/workflow-repository-dispatch.ts
|
|
2729
2802
|
const DEFAULT_BRANCHES = ["main", "master"];
|
|
2730
2803
|
const DEFAULT_BRANCH_EXPRESSION = /\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}/u;
|
|
@@ -3301,6 +3374,10 @@ const checks = z.strictObject({
|
|
|
3301
3374
|
}))),
|
|
3302
3375
|
"workflow-version-single-source": z.exactOptional(enabled(z.strictObject(selection))),
|
|
3303
3376
|
"workflow-credentials": z.exactOptional(enabled(z.strictObject(selection))),
|
|
3377
|
+
"workflow-release-job": z.exactOptional(enabled(z.strictObject({
|
|
3378
|
+
...selection,
|
|
3379
|
+
releaseJobs: z.exactOptional(names)
|
|
3380
|
+
}))),
|
|
3304
3381
|
"workflow-repository-dispatch": z.exactOptional(enabled(z.strictObject({
|
|
3305
3382
|
...selection,
|
|
3306
3383
|
defaultBranches: z.exactOptional(names),
|
|
@@ -3594,6 +3671,12 @@ const registry = {
|
|
|
3594
3671
|
select: (checks) => enabledOptions(checks["workflow-credentials"]),
|
|
3595
3672
|
check: workflowCredentials
|
|
3596
3673
|
}),
|
|
3674
|
+
"workflow-release-job": plain({
|
|
3675
|
+
name: "workflow-release-job",
|
|
3676
|
+
description: "The release job alone holds id-token, runs in an environment and persists no credentials, and no job with id-token passes an npm token secret",
|
|
3677
|
+
select: (checks) => enabledOptions(checks["workflow-release-job"]),
|
|
3678
|
+
check: workflowReleaseJob
|
|
3679
|
+
}),
|
|
3597
3680
|
"workflow-repository-dispatch": plain({
|
|
3598
3681
|
name: "workflow-repository-dispatch",
|
|
3599
3682
|
description: "A repository_dispatch handler does not push to the default branch, auto-merge unguarded, or rely on the default token",
|
|
@@ -3845,4 +3928,4 @@ function proveExhaustive(input) {
|
|
|
3845
3928
|
}) };
|
|
3846
3929
|
}
|
|
3847
3930
|
//#endregion
|
|
3848
|
-
export { ConformanceError, DEFAULT_DERIVED_TYPE_INFERENCES, DEFAULT_INFERENCES, DEFAULT_WORKFLOWS, EXIT_CODES, ExhaustivenessError, PRESET_COMMIT_TYPES, accessOf, aggregateMappers, checkNames, classifyPackages, codecPairs, commandTypes, commitTypes, conformanceSchema, conformanceSection, createGitHubClient, crossSliceRules, cycleRule, deployTools, derivedTypes, dockerfilePackageManager, effectiveEnv, effectivePermissions, eslint, generators, handlerCases, importCrossSlice, importCycles, importIsolatedGroups, importRankSkip, importUphill, instructionSymlinks, isCheckName, isolatedGroupRules, loadWorkflows, migrationsDirectory, packagesPattern, parseUses, parseWorkflow, proveExhaustive, rankSkipRules, registry, removeCase, runChecks, settingsMergeMethods, settingsRequiredChecks, settingsReviewThreadResolution, singleStorybook, transitiveNeeds, typeErrors, uphillRules, workflowActionPinning, workflowCredentials, workflowJobOrdering, workflowMergeGroup, workflowRepositoryDispatch, workflowRunnerResolution, workflowSkippableJobs, workflowUpdateBotCooldown, workflowVersionSingleSource };
|
|
3931
|
+
export { ConformanceError, DEFAULT_DERIVED_TYPE_INFERENCES, DEFAULT_INFERENCES, DEFAULT_WORKFLOWS, EXIT_CODES, ExhaustivenessError, PRESET_COMMIT_TYPES, accessOf, aggregateMappers, checkNames, classifyPackages, codecPairs, commandTypes, commitTypes, conformanceSchema, conformanceSection, createGitHubClient, crossSliceRules, cycleRule, deployTools, derivedTypes, dockerfilePackageManager, effectiveEnv, effectivePermissions, eslint, generators, handlerCases, importCrossSlice, importCycles, importIsolatedGroups, importRankSkip, importUphill, instructionSymlinks, isCheckName, isolatedGroupRules, loadWorkflows, migrationsDirectory, packagesPattern, parseUses, parseWorkflow, proveExhaustive, rankSkipRules, registry, removeCase, runChecks, settingsMergeMethods, settingsRequiredChecks, settingsReviewThreadResolution, singleStorybook, transitiveNeeds, typeErrors, uphillRules, workflowActionPinning, workflowCredentials, workflowJobOrdering, workflowMergeGroup, workflowReleaseJob, workflowRepositoryDispatch, workflowRunnerResolution, workflowSkippableJobs, workflowUpdateBotCooldown, workflowVersionSingleSource };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "workspace-conformance",
|
|
3
3
|
"description": "Conformance checks for a pnpm workspace that ESLint cannot express: type-graph, import-graph and cross-file checks driven by the shared layout section of @exadev/config",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.6.0",
|
|
5
5
|
"bin": {
|
|
6
6
|
"workspace-conformance": "./dist/cli.js"
|
|
7
7
|
},
|