workspace-conformance 1.4.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 +18 -1
- package/dist/cli.js +219 -8
- package/dist/index.cjs +221 -8
- package/dist/index.d.cts +65 -1
- package/dist/index.d.ts +65 -1
- package/dist/index.js +220 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -117,6 +117,18 @@ The type-graph checks build a ts-morph program from the files their options sele
|
|
|
117
117
|
|
|
118
118
|
Codes: `aggregate-mappers/missing-mapper` (reported at the aggregate's declaration), `aggregate-mappers/no-adapters`. Limits: it checks that a file exists, not what is in it; a type re-exported by the contract counts as an aggregate, and a type re-exported under another name (`export type { Created as CreatedAlias }`) counts once for each name it is exported as, each needing a mapper, so helper types and aliases need `exclude`; a type in an exported namespace, or in a module re-exported as a namespace (`export * as ns`), is an aggregate under its qualified name (`ns.Type`, which is also what `{name}` and `exclude` use); a placeholder with no value, a glob that matches no contract and a missing tsconfig are configuration errors, which name the option (`checks.aggregate-mappers.contracts`) and not the value written in it.
|
|
119
119
|
|
|
120
|
+
**`codec-pairs`** verifies that every encoder a codec file exports has the decoder its name implies, exported by the same file, and every decoder the encoder. A name is an encoder or a decoder when it fits that option's template: with `encoder: 'encode{name}'` and `decoder: 'decode{name}'`, `encodeInvoice` needs `decodeInvoice` and `decodeReceipt` needs `encodeReceipt`. It checks only that both halves exist; that each pair round-trips is a property test (decode after encode gives back the value) and stays the repository's own, since a type graph cannot run code.
|
|
121
|
+
|
|
122
|
+
| Option | Meaning |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `codecs` | Globs of codec files. Each file is judged on its own exports, re-exports included. |
|
|
125
|
+
| `encoder` | Name template of an encoder, such as `encode{name}` or `{name}Encoder`. `{name}` appears exactly once, beside other text, and is the only placeholder, or the section fails to load. |
|
|
126
|
+
| `decoder` | Name template of a decoder, in the same form and different from `encoder`. |
|
|
127
|
+
| `exclude` | Names of exported encoders and decoders that need no counterpart, such as an encoder for a format that is only ever written. |
|
|
128
|
+
| `tsConfig` | See above. |
|
|
129
|
+
|
|
130
|
+
Codes: `codec-pairs/missing-decoder` and `codec-pairs/missing-encoder`, each reported at the declaration of the half that exists. An export whose name fits neither template is not a codec and is ignored, not reported, so a codec file may also export its helpers, schemas and types without listing them in `exclude`; `exclude` is for a half that deliberately has no counterpart. Limits: only values count (functions, constants, destructured constants, classes and enums), so an interface or type alias whose name fits a template is ignored; the counterpart is looked up by name among the same file's exports, not by signature, and a value re-exported under another name counts under each name it is exported as; a value in an exported namespace, or in a module re-exported as a namespace (`export * as ns`), is judged under its qualified name (`ns.encodeInvoice`, which is also what `exclude` uses) and pairs only with a counterpart in the same namespace; a glob that matches no codec file and a missing tsconfig are configuration errors.
|
|
131
|
+
|
|
120
132
|
**`command-types`** verifies that every command type is derived from a schema and not written by hand. An exported alias must apply a generic named in `inferences` (`infer`, `input`, `output`, `TypeOf`, `InferInput` and `InferOutput` by default, the last name of the reference, so `z.infer` and Valibot's `v.InferOutput` both count) to `typeof schema`, where the type of `schema` has the `~standard` member every Standard Schema has. The schema is recognised by type, so it may be imported or renamed and need not come from Zod. The generic may be imported under another name (`import type { infer as Infer }`), and an alias may stand between the export and the inference (`type Base = z.infer<typeof schema>; export type Created = Base;`): a local alias without type parameters is judged by the type it is written as. An alias with type parameters is not followed and is hand-written.
|
|
121
133
|
|
|
122
134
|
| Option | Meaning |
|
|
@@ -212,6 +224,7 @@ Every workflow check takes `workflows` (globs of the files to read, `.github/wor
|
|
|
212
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` |
|
|
213
225
|
| `workflow-version-single-source` | A setup step does not hold a literal runtime version when `.nvmrc` or `.tool-versions` already does. | `literal-version` |
|
|
214
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` |
|
|
215
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` |
|
|
216
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` |
|
|
217
230
|
| `workflow-merge-group` | A workflow that runs for `pull_request` also has a `merge_group` trigger. | `missing-trigger` |
|
|
@@ -229,6 +242,10 @@ Codes are `<check>/<code>`, so `workflow-job-ordering/release-before-deploy`. Th
|
|
|
229
242
|
|
|
230
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.
|
|
231
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
|
+
|
|
232
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.
|
|
233
250
|
|
|
234
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.
|
|
@@ -294,7 +311,7 @@ it('conforms to the workspace layout', async () => {
|
|
|
294
311
|
});
|
|
295
312
|
```
|
|
296
313
|
|
|
297
|
-
Every check is also exported as a function taking `{ cwd, options }` (and `layout` for the import checks): `aggregateMappers`, `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? }`.
|
|
298
315
|
|
|
299
316
|
### Proving a handler map is exhaustive
|
|
300
317
|
|
package/dist/cli.js
CHANGED
|
@@ -171,6 +171,23 @@ function expandTemplate(template, values) {
|
|
|
171
171
|
return value;
|
|
172
172
|
});
|
|
173
173
|
}
|
|
174
|
+
const NAME_PLACEHOLDER = "{name}";
|
|
175
|
+
/**
|
|
176
|
+
* What a name template must be, as a configuration error says it. It does not quote the template, as no configuration error quotes a value.
|
|
177
|
+
*/
|
|
178
|
+
const NAME_TEMPLATE_REQUIREMENT = "needs {name} exactly once, other text beside it and no other placeholder";
|
|
179
|
+
const ANY_PLACEHOLDER = /\{[a-z]+\}/u;
|
|
180
|
+
/**
|
|
181
|
+
* `template` split at `{name}`, or `undefined` unless it holds `{name}` exactly once, other text beside it and no other placeholder. Without other text every name would match, and with `{name}` twice a name could be split more than one way.
|
|
182
|
+
*/
|
|
183
|
+
function parseNameTemplate(template) {
|
|
184
|
+
const [before, after, ...rest] = template.split(NAME_PLACEHOLDER);
|
|
185
|
+
if (before === void 0 || after === void 0 || rest.length > 0 || before + after === "" || ANY_PLACEHOLDER.test(before + after)) return;
|
|
186
|
+
return {
|
|
187
|
+
before,
|
|
188
|
+
after
|
|
189
|
+
};
|
|
190
|
+
}
|
|
174
191
|
//#endregion
|
|
175
192
|
//#region src/ts-project.ts
|
|
176
193
|
/**
|
|
@@ -284,6 +301,97 @@ const aggregateMappers = async ({ cwd, options }) => {
|
|
|
284
301
|
return violations.sort((a, b) => a.file.localeCompare(b.file) || (a.location?.line ?? 0) - (b.location?.line ?? 0) || a.message.localeCompare(b.message));
|
|
285
302
|
};
|
|
286
303
|
//#endregion
|
|
304
|
+
//#region src/checks/codec-pairs.ts
|
|
305
|
+
function collect(container, prefix, found, entered) {
|
|
306
|
+
for (const [name, declarations] of container.getExportedDeclarations()) {
|
|
307
|
+
const value = declarations.find((declaration) => !Node.isInterfaceDeclaration(declaration) && !Node.isTypeAliasDeclaration(declaration) && !Node.isModuleDeclaration(declaration) && !Node.isSourceFile(declaration));
|
|
308
|
+
if (value !== void 0) found.set(`${prefix}${name}`, value);
|
|
309
|
+
for (const member of declarations) if (Node.isModuleDeclaration(member)) collect(member, `${prefix}${name}.`, found, entered);
|
|
310
|
+
else if (Node.isSourceFile(member) && !entered.has(member)) collect(member, `${prefix}${name}.`, found, /* @__PURE__ */ new Set([...entered, member]));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* The values `file` exports, by name, wherever they are declared: a re-export counts. Those inside an exported namespace, or in a module re-exported as a namespace (`export * as ns from './x'`), are included under their qualified name (`Namespace.value`). Interfaces and type aliases are types, not codecs, and a namespace is where codecs live rather than one itself, so all three are left out.
|
|
315
|
+
*/
|
|
316
|
+
function exportedValues(file) {
|
|
317
|
+
const found = /* @__PURE__ */ new Map();
|
|
318
|
+
collect(file, "", found, /* @__PURE__ */ new Set([file]));
|
|
319
|
+
return found;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Where the declaration's name is written, or where the declaration starts when it has no name node.
|
|
323
|
+
*/
|
|
324
|
+
function valueLocation(declaration) {
|
|
325
|
+
const nameNode = Node.hasName(declaration) ? declaration.getNameNode() : void 0;
|
|
326
|
+
const { line, column } = declaration.getSourceFile().getLineAndColumnAtPos((nameNode ?? declaration).getStart());
|
|
327
|
+
return {
|
|
328
|
+
line,
|
|
329
|
+
column
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* The template of an option, which the configuration has already validated; a template that does not parse is a configuration error naming the option.
|
|
334
|
+
*/
|
|
335
|
+
function templateOf(options, option) {
|
|
336
|
+
const template = parseNameTemplate(options[option]);
|
|
337
|
+
if (template === void 0) throw new ConformanceError(`checks.codec-pairs.${option}: ${NAME_TEMPLATE_REQUIREMENT}`);
|
|
338
|
+
return template;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* The shared name a qualified export name gives under `template`, or `undefined` when its last segment does not fit the template. The namespace part is kept apart so a counterpart is looked for in the same namespace.
|
|
342
|
+
*/
|
|
343
|
+
function sharedName(qualified, template) {
|
|
344
|
+
const namespace = qualified.slice(0, qualified.lastIndexOf(".") + 1);
|
|
345
|
+
const local = qualified.slice(namespace.length);
|
|
346
|
+
if (local.length <= template.before.length + template.after.length || !local.startsWith(template.before) || !local.endsWith(template.after)) return;
|
|
347
|
+
return {
|
|
348
|
+
namespace,
|
|
349
|
+
name: local.slice(template.before.length, local.length - template.after.length)
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Every encoder a codec file exports has the decoder its name implies, exported by the same file, and every decoder the encoder. A name is an encoder or a decoder when it fits that template; other exports are not codecs and are not judged. A half re-exported from another file counts, and is reported where it is declared.
|
|
354
|
+
*
|
|
355
|
+
* It checks that the counterpart is exported, not that the two round-trip: that is a property test of the repository's own.
|
|
356
|
+
*/
|
|
357
|
+
const codecPairs = async ({ cwd, options }) => {
|
|
358
|
+
const { project, files } = await createProject(cwd, options.tsConfig, options.codecs, {
|
|
359
|
+
check: "codec-pairs",
|
|
360
|
+
files: "codecs"
|
|
361
|
+
});
|
|
362
|
+
const excluded = new Set(options.exclude);
|
|
363
|
+
const encoder = templateOf(options, "encoder");
|
|
364
|
+
const decoder = templateOf(options, "decoder");
|
|
365
|
+
const halves = [{
|
|
366
|
+
template: encoder,
|
|
367
|
+
counterpart: decoder,
|
|
368
|
+
missing: "decoder"
|
|
369
|
+
}, {
|
|
370
|
+
template: decoder,
|
|
371
|
+
counterpart: encoder,
|
|
372
|
+
missing: "encoder"
|
|
373
|
+
}];
|
|
374
|
+
const violations = [];
|
|
375
|
+
for (const file of files) {
|
|
376
|
+
const values = exportedValues(project.getSourceFileOrThrow(resolve(cwd, file)));
|
|
377
|
+
for (const [qualified, declaration] of values) {
|
|
378
|
+
if (excluded.has(qualified)) continue;
|
|
379
|
+
for (const { template, counterpart, missing } of halves) {
|
|
380
|
+
const shared = sharedName(qualified, template);
|
|
381
|
+
if (shared === void 0) continue;
|
|
382
|
+
const expected = `${shared.namespace}${counterpart.before}${shared.name}${counterpart.after}`;
|
|
383
|
+
if (!values.has(expected)) violations.push({
|
|
384
|
+
code: `codec-pairs/missing-${missing}`,
|
|
385
|
+
message: `${qualified} has no ${missing}: ${file} does not export ${expected}`,
|
|
386
|
+
file: relativePosix(cwd, declaration.getSourceFile().getFilePath()),
|
|
387
|
+
location: valueLocation(declaration)
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return violations.sort((a, b) => a.file.localeCompare(b.file) || (a.location?.line ?? 0) - (b.location?.line ?? 0) || a.message.localeCompare(b.message));
|
|
393
|
+
};
|
|
394
|
+
//#endregion
|
|
287
395
|
//#region src/checks/type-references.ts
|
|
288
396
|
/**
|
|
289
397
|
* The identifier a reference ends in: `infer` in `z.infer`, the name itself when it is not qualified.
|
|
@@ -990,6 +1098,9 @@ function runsOnOf(reader, value, what) {
|
|
|
990
1098
|
group
|
|
991
1099
|
};
|
|
992
1100
|
}
|
|
1101
|
+
function environmentOf(reader, value, what) {
|
|
1102
|
+
return isRecord(value) ? reader.text(value["name"], `${what}.name`) : reader.text(value, what);
|
|
1103
|
+
}
|
|
993
1104
|
function stepOf(reader, value, jobPath, index) {
|
|
994
1105
|
const path = [
|
|
995
1106
|
...jobPath,
|
|
@@ -1022,6 +1133,7 @@ function jobOf(reader, id, value) {
|
|
|
1022
1133
|
needs: reader.list(job["needs"], `${what}.needs`),
|
|
1023
1134
|
condition: reader.text(job["if"], `${what}.if`),
|
|
1024
1135
|
permissions: permissionsOf(reader, job["permissions"], `${what}.permissions`),
|
|
1136
|
+
environment: environmentOf(reader, job["environment"], `${what}.environment`),
|
|
1025
1137
|
env: reader.texts(job["env"], `${what}.env`),
|
|
1026
1138
|
runsOn: runsOnOf(reader, job["runs-on"], `${what}.runs-on`),
|
|
1027
1139
|
timeoutMinutes: reader.text(job["timeout-minutes"], `${what}.timeout-minutes`),
|
|
@@ -1057,6 +1169,7 @@ function parseWorkflow(file, source) {
|
|
|
1057
1169
|
triggers,
|
|
1058
1170
|
triggerLocations: triggerLocationsOf(reader, triggers, root["on"]),
|
|
1059
1171
|
permissions: permissionsOf(reader, root["permissions"], "permissions"),
|
|
1172
|
+
permissionsLocation: root["permissions"] === void 0 ? void 0 : reader.location(["permissions"]),
|
|
1060
1173
|
env: reader.texts(root["env"], "env"),
|
|
1061
1174
|
jobs: Object.entries(reader.record(root["jobs"], "jobs")).map(([id, job]) => jobOf(reader, id, job)),
|
|
1062
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`) ?? ""]))
|
|
@@ -1235,6 +1348,14 @@ function callsAlways(condition) {
|
|
|
1235
1348
|
* The default ids of junction jobs, shared by the checks that look for one.
|
|
1236
1349
|
*/
|
|
1237
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"];
|
|
1238
1359
|
//#endregion
|
|
1239
1360
|
//#region src/checks/settings-required-checks.ts
|
|
1240
1361
|
/**
|
|
@@ -1370,10 +1491,6 @@ const PACKAGE_MANAGER_OPTIONS = String.raw`(?:\s+(?:(?:--filter|-F|--workspace|-
|
|
|
1370
1491
|
* Commands that publish a package to a registry.
|
|
1371
1492
|
*/
|
|
1372
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");
|
|
1373
|
-
/**
|
|
1374
|
-
* The variables a token for the registry is passed in.
|
|
1375
|
-
*/
|
|
1376
|
-
const TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
|
|
1377
1494
|
const PROVENANCE_FLAG = /--provenance(?:=true)?(?:\s|$)/u;
|
|
1378
1495
|
/**
|
|
1379
1496
|
* Whether the `package.json` in the working directory asks for provenance with `publishConfig.provenance: true`, which applies to every publish of that package.
|
|
@@ -1397,7 +1514,7 @@ function attestationViolations(workflow, job) {
|
|
|
1397
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.
|
|
1398
1515
|
*/
|
|
1399
1516
|
function passesToken(workflow, job) {
|
|
1400
|
-
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] ?? "") !== ""));
|
|
1401
1518
|
}
|
|
1402
1519
|
function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
|
|
1403
1520
|
if (accessOf(effectivePermissions(workflow, job), "id-token") !== "write" || passesToken(workflow, job)) return [];
|
|
@@ -1405,9 +1522,9 @@ function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
|
|
|
1405
1522
|
const publishes = scriptCommands(step.run).filter((line) => PUBLISH_COMMAND.test(line));
|
|
1406
1523
|
if (publishes.length === 0) return [];
|
|
1407
1524
|
const env = effectiveEnv(workflow, job, step);
|
|
1408
|
-
const blanked =
|
|
1525
|
+
const blanked = REGISTRY_TOKEN_VARIABLES.every((variable) => env[variable] === "");
|
|
1409
1526
|
const provenance = provenanceInManifest || publishes.some((line) => PROVENANCE_FLAG.test(line)) || env["NPM_CONFIG_PROVENANCE"] === "true";
|
|
1410
|
-
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)];
|
|
1411
1528
|
});
|
|
1412
1529
|
}
|
|
1413
1530
|
/**
|
|
@@ -1425,7 +1542,6 @@ const workflowCredentials = async ({ cwd, options }) => {
|
|
|
1425
1542
|
};
|
|
1426
1543
|
//#endregion
|
|
1427
1544
|
//#region src/checks/workflow-job-ordering.ts
|
|
1428
|
-
const DEFAULT_RELEASE_JOBS = ["release"];
|
|
1429
1545
|
const DEFAULT_DEPLOY_JOBS = ["deploy"];
|
|
1430
1546
|
const DEFAULT_DOCS_DEPLOY_JOBS = ["docs-deploy"];
|
|
1431
1547
|
const DEFAULT_BRANCH = "main";
|
|
@@ -1573,6 +1689,71 @@ const workflowMergeGroup = async ({ cwd, options }) => (await loadWorkflows(cwd,
|
|
|
1573
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)];
|
|
1574
1690
|
});
|
|
1575
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
|
|
1576
1757
|
//#region src/checks/workflow-repository-dispatch.ts
|
|
1577
1758
|
const DEFAULT_BRANCHES = ["main", "master"];
|
|
1578
1759
|
const DEFAULT_BRANCH_EXPRESSION = /\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}/u;
|
|
@@ -3158,6 +3339,12 @@ const registry = {
|
|
|
3158
3339
|
select: (checks) => enabledOptions(checks["aggregate-mappers"]),
|
|
3159
3340
|
check: aggregateMappers
|
|
3160
3341
|
}),
|
|
3342
|
+
"codec-pairs": plain({
|
|
3343
|
+
name: "codec-pairs",
|
|
3344
|
+
description: "Every exported encoder in a codec file has a decoder under the naming templates, and every decoder an encoder",
|
|
3345
|
+
select: (checks) => enabledOptions(checks["codec-pairs"]),
|
|
3346
|
+
check: codecPairs
|
|
3347
|
+
}),
|
|
3161
3348
|
"command-types": plain({
|
|
3162
3349
|
name: "command-types",
|
|
3163
3350
|
description: "Every exported command type is derived from a schema, not hand-written",
|
|
@@ -3266,6 +3453,12 @@ const registry = {
|
|
|
3266
3453
|
select: (checks) => enabledOptions(checks["workflow-credentials"]),
|
|
3267
3454
|
check: workflowCredentials
|
|
3268
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
|
+
}),
|
|
3269
3462
|
"workflow-repository-dispatch": plain({
|
|
3270
3463
|
name: "workflow-repository-dispatch",
|
|
3271
3464
|
description: "A repository_dispatch handler does not push to the default branch, auto-merge unguarded, or rely on the default token",
|
|
@@ -3337,6 +3530,10 @@ function compiles(source) {
|
|
|
3337
3530
|
*/
|
|
3338
3531
|
const pattern = z.string().min(1).refine(compiles, "not a valid regular expression");
|
|
3339
3532
|
const patterns = z.array(pattern);
|
|
3533
|
+
/**
|
|
3534
|
+
* A name template of `codec-pairs`.
|
|
3535
|
+
*/
|
|
3536
|
+
const nameTemplate = z.string().refine((template) => parseNameTemplate(template) !== void 0, NAME_TEMPLATE_REQUIREMENT);
|
|
3340
3537
|
const severity = z.enum(["warn", "error"]);
|
|
3341
3538
|
const requiredRules = z.record(z.string().min(1), severity);
|
|
3342
3539
|
const selection = {
|
|
@@ -3369,6 +3566,16 @@ const checks = z.strictObject({
|
|
|
3369
3566
|
exclude: z.exactOptional(names),
|
|
3370
3567
|
tsConfig: z.exactOptional(path)
|
|
3371
3568
|
}))),
|
|
3569
|
+
"codec-pairs": z.exactOptional(enabled(z.strictObject({
|
|
3570
|
+
codecs: paths,
|
|
3571
|
+
encoder: nameTemplate,
|
|
3572
|
+
decoder: nameTemplate,
|
|
3573
|
+
exclude: z.exactOptional(names),
|
|
3574
|
+
tsConfig: z.exactOptional(path)
|
|
3575
|
+
}).refine((options) => options.encoder !== options.decoder, {
|
|
3576
|
+
path: ["decoder"],
|
|
3577
|
+
message: "the decoder template is the encoder template, so every name would be its own pair"
|
|
3578
|
+
}))),
|
|
3372
3579
|
"command-types": z.exactOptional(enabled(z.strictObject({
|
|
3373
3580
|
commands: paths,
|
|
3374
3581
|
exclude: z.exactOptional(names),
|
|
@@ -3446,6 +3653,10 @@ const checks = z.strictObject({
|
|
|
3446
3653
|
}))),
|
|
3447
3654
|
"workflow-version-single-source": z.exactOptional(enabled(z.strictObject(selection))),
|
|
3448
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
|
+
}))),
|
|
3449
3660
|
"workflow-repository-dispatch": z.exactOptional(enabled(z.strictObject({
|
|
3450
3661
|
...selection,
|
|
3451
3662
|
defaultBranches: z.exactOptional(names),
|