diff --git a/.github/workflows/site-ci.yml b/.github/workflows/site-ci.yml index 5bdcc14..af1741e 100644 --- a/.github/workflows/site-ci.yml +++ b/.github/workflows/site-ci.yml @@ -147,7 +147,10 @@ jobs: "https://github.com/gitleaks/gitleaks/releases/download/v${version}/${tarball}" echo "${sha} $RUNNER_TEMP/$tarball" | sha256sum -c - tar -xzf "$RUNNER_TEMP/$tarball" -C "$RUNNER_TEMP" gitleaks - "$RUNNER_TEMP/gitleaks" git --redact --no-banner --verbose . + # Redacted SARIF for upload-sarif.yml; the scan still fails on findings. + mkdir -p "$RUNNER_TEMP/sarif" + "$RUNNER_TEMP/gitleaks" git --redact --no-banner --verbose \ + --report-format sarif --report-path "$RUNNER_TEMP/sarif/gitleaks.sarif" . shell: bash - name: Check package manager @@ -468,3 +471,29 @@ jobs: path: | playwright-report/ test-results/ + + # SARIF for upload-sarif.yml. Runs after failures too, so findings that + # failed this job still reach code scanning. zizmor scans every workflow + # (not just changed ones) because code scanning closes any alert missing + # from the next upload; with --format sarif it never fails the job. + - name: Setup uv (SARIF) + if: ${{ !cancelled() }} + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + + - name: zizmor SARIF + if: ${{ !cancelled() && hashFiles('.github/workflows/*.yml', '.github/workflows/*.yaml') != '' }} + run: | + mkdir -p "$RUNNER_TEMP/sarif" + uvx zizmor@1.30.1 --format sarif .github > "$RUNNER_TEMP/sarif/zizmor.sarif" + shell: bash + env: + GH_TOKEN: ${{ github.token }} + + - name: Keep SARIF + if: ${{ !cancelled() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: voidflow-sarif + path: ${{ runner.temp }}/sarif/*.sarif + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/tool-ci.yml b/.github/workflows/tool-ci.yml index f729b3c..11aab15 100644 --- a/.github/workflows/tool-ci.yml +++ b/.github/workflows/tool-ci.yml @@ -63,7 +63,10 @@ jobs: "https://github.com/gitleaks/gitleaks/releases/download/v${version}/${tarball}" echo "${sha} $RUNNER_TEMP/$tarball" | sha256sum -c - tar -xzf "$RUNNER_TEMP/$tarball" -C "$RUNNER_TEMP" gitleaks - "$RUNNER_TEMP/gitleaks" git --redact --no-banner --verbose . + # Redacted SARIF for upload-sarif.yml; the scan still fails on findings. + mkdir -p "$RUNNER_TEMP/sarif" + "$RUNNER_TEMP/gitleaks" git --redact --no-banner --verbose \ + --report-format sarif --report-path "$RUNNER_TEMP/sarif/gitleaks.sarif" . shell: bash - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 @@ -211,3 +214,29 @@ jobs: - name: Check licenses if: steps.scripts.outputs.check_licenses == 'true' && (steps.changes.outputs.lockfile == 'true' || steps.changes.outputs.manifest == 'true' || steps.changes.outputs.scripts == 'true') run: bun run check:licenses + + # SARIF for upload-sarif.yml. Runs after failures too, so findings that + # failed this job still reach code scanning. zizmor scans every workflow + # (not just changed ones) because code scanning closes any alert missing + # from the next upload; with --format sarif it never fails the job. + - name: Setup uv (SARIF) + if: ${{ !cancelled() }} + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + + - name: zizmor SARIF + if: ${{ !cancelled() && hashFiles('.github/workflows/*.yml', '.github/workflows/*.yaml') != '' }} + run: | + mkdir -p "$RUNNER_TEMP/sarif" + uvx zizmor@1.30.1 --format sarif .github > "$RUNNER_TEMP/sarif/zizmor.sarif" + shell: bash + env: + GH_TOKEN: ${{ github.token }} + + - name: Keep SARIF + if: ${{ !cancelled() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: voidflow-sarif + path: ${{ runner.temp }}/sarif/*.sarif + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/upload-sarif.yml b/.github/workflows/upload-sarif.yml new file mode 100644 index 0000000..3d890af --- /dev/null +++ b/.github/workflows/upload-sarif.yml @@ -0,0 +1,65 @@ +name: Upload SARIF + +# Uploads the gitleaks and zizmor SARIF that site-ci.yml and tool-ci.yml keep +# as the `voidflow-sarif` artifact, so findings show up in code scanning (and +# in voidlight's counts) instead of only failing a run. +# +# A separate workflow because uploading needs `security-events: write`: a +# reusable workflow can't ask for more than its caller grants, so adding it to +# site-ci/tool-ci would break every caller that doesn't grant it. Opt in by +# adding a second job to the caller: +# +# sarif: +# needs: validate +# if: ${{ !cancelled() && needs.validate.result != 'skipped' }} +# permissions: +# contents: read +# actions: read +# security-events: write +# uses: taraxvoid/voidflow/.github/workflows/upload-sarif.yml@ # +# +# Code scanning is free on public repositories; private ones need GitHub Code +# Security, without which the upload fails. + +on: + workflow_call: + inputs: + runner: + description: Runner label. Defaults to a GitHub-hosted runner. + type: string + default: ubuntu-latest + +permissions: + contents: read + actions: read + security-events: write + +jobs: + upload: + name: Upload SARIF + runs-on: ${{ inputs.runner }} + timeout-minutes: 5 + steps: + - name: Fetch SARIF + id: fetch + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + # A validate run that stopped before keeping SARIF leaves nothing to upload. + continue-on-error: true + with: + name: voidflow-sarif + path: sarif + + # One category per tool, so each upload replaces only its own results. + - name: Upload gitleaks + if: ${{ steps.fetch.outcome == 'success' && hashFiles('sarif/gitleaks.sarif') != '' }} + uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + with: + sarif_file: sarif/gitleaks.sarif + category: gitleaks + + - name: Upload zizmor + if: ${{ steps.fetch.outcome == 'success' && hashFiles('sarif/zizmor.sarif') != '' }} + uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + with: + sarif_file: sarif/zizmor.sarif + category: zizmor diff --git a/.gitignore b/.gitignore index e3753cf..5b2be8d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules/ .env.* .env.fish !.env.example +fnox.local.toml # Editor / bots .poolside/ @@ -15,7 +16,7 @@ node_modules/ .vscode/ .idea .superpowers -docs/superpowers +docs/ # mise state from runs with HOME at the repo root /.cache/ diff --git a/.release-please/next-manifest.json b/.release-please/next-manifest.json index ed21d28..ac8b12a 100644 --- a/.release-please/next-manifest.json +++ b/.release-please/next-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.13.0" + ".": "0.16.0-next" } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bfecb0a..fce1345 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,3 +53,175 @@ just rulesets-apply --prune # also delete rulesets listed under `retire` Rulesets not in the config are reported and left alone. Repos where the API returns 403 (private repos on a plan without rulesets) are skipped and listed. + +## Cloudflare deploy tokens as code + +[`cloudflare/tokens.ts`](cloudflare/tokens.ts) holds the config shape and the +plan logic; the fleet (repos, accounts, envs) lives in the org's own infra +repo (see [Fleet config](#fleet-config---config)), one token per repo and +env. Each token is owned by its env's account and scoped to +one Worker (the Workers Editor role on it), plus Workers Routes Write on the +zones of its routes only, with a 90-day expiry. An env whose Worker lives in +another account (say a separate preview Worker kept apart from prod) sets its +own `account`; otherwise it uses the repo's. + +The Worker and zones come from the repo's own wrangler config on its default +branch (`wrangler.jsonc`, or the env's `wrangler` path), read the way wrangler +reads it: the `env.` block named like the env if there is one (or +`wranglerEnv`), else the top level. Each route's host maps to the account zone +it sits in, so a route added to wrangler shows up as drift on the next plan, +and a host in none of the account's zones fails the plan. An env can still +spell out `worker` and `zones` instead; `zones: []` grants none, e.g. for a +Worker Preview token, which deploys no routes. + +### Where the secret goes + +By default each env's token goes into an environment secret, +`CLOUDFLARE_API_TOKEN` in the GitHub environment named like the env. Only +jobs with `environment: ` read it, so the environment's deployment +branch policy (`main` only for `prod`) is what keeps a pull request job from +reading the prod token. That is the recommended shape: one deploy job for +`main` with `environment: prod`, one for pull requests with +`environment: preview`. + +```ts +envs: [ + { env: 'prod' }, // Worker and zones from wrangler.jsonc + { env: 'preview', zones: [] }, // Worker Previews: same Worker, no routes +], +``` + +A repo that deploys without environments sets `secret: 'repo'` on one env: its +token goes into the repository secret, which every job reads. Only one env +per repo can do that. When every env uses environments, a plan reports a +leftover repository `CLOUDFLARE_API_TOKEN` (a pre-voidflow token that jobs +without an environment still deploy with), and `--prune` deletes it along +with the `retire` tokens. + +A Worker Preview runs on the same Worker as prod, so unless Cloudflare grows a +preview-only permission, the preview token can deploy to prod too. Keeping it +in its own environment still scopes and rotates it on its own. + +The targets need `CF_TOKEN_ADMIN`: a user-owned token (account-owned tokens +reach only their own account) that includes every account in the config, with +Account API Tokens Edit, Workers Scripts Read and Zone Read on each, a short +expiry and an IP filter. Adding a repo in a new account means adding that +account to this token. It can mint +deploy tokens, so it stays on a maintainer's machine and never goes into CI. +[fnox](https://github.com/jdx/fnox) (installed by `mise install`) supplies +it: the committed [`fnox.toml`](fnox.toml) declares the secret, but no value, +and a `cf-admin` provider backed by the macOS Keychain. Store the value once +with `fnox set -k voidflow-cf-token-admin CF_TOKEN_ADMIN` (it prompts with +hidden input), and confirm with `just doctor`. To use another fnox provider +(age, 1Password, `pass`...), redefine `[providers.cf-admin]` in a gitignored +`fnox.local.toml` at the repo root (one per checkout or worktree). If +`CF_TOKEN_ADMIN` is already in the environment, the targets use it and skip +fnox. + +From the infra repo, whose recipes pass its config to these: + +```sh +just cf-tokens-plan # read-only: diff every repo's tokens against the config +just cf-tokens-plan --check # exit 1 on drift or a token within 14 days of expiry +just cf-tokens-apply # create/update, rotate tokens due, set CLOUDFLARE_API_TOKEN per env +just cf-tokens-apply --rotate # rotate every token now +just cf-tokens-apply --prune # also delete `retire` tokens and a leftover repo-wide secret +just cf-tokens-groups # live names of the permission group ids the config uses +``` + +The plan prints when each token was last used, so a CI deploy shows up there. +To prove a token's policies are enough before CI depends on them, run the +deploy with a throwaway token that has the same policies. From the site's +checkout: + +```sh +just -f ../infra/justfile cf-tokens-try transbot prod bunx wrangler deploy +``` + +The real token can't be used locally (its value only exists in GitHub), and +a shell holding a wider token (an admin token, `wrangler login`) would hide a +missing grant. The command gets the throwaway token as `CLOUDFLARE_API_TOKEN`, +with `CF_TOKEN_ADMIN` removed, and the token is deleted afterwards (it +expires within the hour regardless). + +A plan needs `gh` as well as `CF_TOKEN_ADMIN`: it reads each repo's wrangler +config and secret names. New values go straight from the API into `gh secret +set` and are never printed. A rotation creates the new token, sets the +secret, then deletes the old token, so a deploy in between still works. If +the secret can't be set, the new token is deleted again. A run that stops +partway can leave two tokens with one name, and nothing says which one GitHub +holds, so the next run rotates: a fresh token into the secret, then both old +ones deleted. Policy drift is fixed in place without changing the value. + +Limits worth knowing: + +- Per-Worker grants apply only to Workers that exist. A Worker's first deploy + needs an admin token; the tool refuses to plan for a Worker it cannot find. +- Custom Domains do not support per-Worker roles yet. If a deploy fails on the + domain with only the zone grant, add `fallback: { reason, since }` to that + env. Its token then also gets account-wide Workers Scripts Write, every plan + prints it, and `apply` sets the variable `CF_TOKEN_FALLBACK` next to the + secret. Pass that to the deploy action so every deploy warns: + + ```yaml + - uses: taraxvoid/voidflow/actions/cloudflare-deploy@ # + with: + env: prod + cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }} + token-fallback: ${{ vars.CF_TOKEN_FALLBACK }} + ``` + +Cut over one repo at a time: `just cf-tokens-apply --repo `, deploy, then +`just cf-tokens-apply --repo --prune` to delete its old token. Repo +tooling, not part of the published package. + +## Fleet config (`--config`) + +voidflow holds the tools; each org keeps its fleet map (repo names, account +ids, envs) in its own private infra repo, e.g. `taraxvoid/infra`, as modules +that export `REPOS` (or a default). The infra repo's justfile wraps voidflow's +recipes with `--config` pointing at them. `cf-tokens-*` requires it; +`rulesets-*` still falls back to the built-in `REPOS` in `rulesets/index.ts` +until that moves out too. + +```ts +// cf-tokens.ts in the org's infra repo +export const REPOS = { + 'example-org/site': { + account: '', + envs: [ + { env: 'prod' }, // env.prod in its wrangler.jsonc, else the top level + { env: 'preview', zones: [] }, + ], + retire: [], + }, +} +``` + +```just +# justfile in the org's infra repo +voidflow := env_var_or_default("VOIDFLOW", home_directory() / "projects/personal/voidflow") +vf := "just -f " + quote(voidflow / "justfile") +tokens := justfile_directory() / "cf-tokens.ts" + +cf-tokens-plan *args: + {{ vf }} cf-tokens-plan --config {{ quote(tokens) }} {{ args }} + +# [no-cd]: the command runs in the site checkout `just -f` was called from +[no-cd] +cf-tokens-try repo env +cmd: + {{ vf }} cf-tokens-try {{ quote(tokens) }} {{ repo }} {{ env }} {{ cmd }} +``` + +A rulesets config can use the tier model, where any field left out takes +`tool()`'s value (main only, the `validate` check, CODEOWNERS, linear +history, so `{}` is a valid repo) and an unknown field is an error. Or it can +spell a repo's rulesets out in full as `{ rulesets: [...], retire: [...] }` when its protection doesn't fit +the tiers. A snapshot of what GitHub has now (`gh api +repos//rulesets/`, keeping `name`, `target`, `enforcement`, +`conditions`, `bypass_actors` and `rules`) plans with no drift. + +A relative path resolves from the directory `just` runs in. Each config is +checked before any API call, and every problem is reported by its path. +`CF_TOKEN_ADMIN` then needs to cover that org's accounts, and `gh` needs admin +on its repos. diff --git a/README.md b/README.md index 45739bc..0f64a92 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,38 @@ jobs: The required status check is `validate / Validate`. +## Code scanning (SARIF) + +`site-ci.yml` and `tool-ci.yml` write gitleaks and zizmor results as SARIF +and keep them as the `voidflow-sarif` artifact (7 days). gitleaks still fails +the job on a finding; the zizmor SARIF run scans every workflow and never +fails the job (the zizmor gate in `tool-ci.yml` is unchanged). Both steps run +after a failure too, so the findings that failed a run are the ones uploaded. + +`upload-sarif.yml` sends them to code scanning, which is what voidlight +counts. It is a separate workflow because it needs `security-events: write`, +and a reusable workflow can't ask for more than its caller grants: adding it to +the validate workflows would break every caller that doesn't. Add a second job: + +```yaml +jobs: + validate: + uses: taraxvoid/voidflow/.github/workflows/site-ci.yml@ # vX.Y.Z + sarif: + needs: validate + if: ${{ !cancelled() && needs.validate.result != 'skipped' }} + permissions: + contents: read + actions: read + security-events: write + uses: taraxvoid/voidflow/.github/workflows/upload-sarif.yml@ # vX.Y.Z +``` + +Each tool uploads under its own category (`gitleaks`, `zizmor`), so a later +run closes only that tool's fixed alerts. gitleaks SARIF is redacted. +Code scanning is free on public repositories; private ones need GitHub Code +Security, without which the upload step fails. + ## Deploys `resolve-env.yml` maps the branch to an environment: `main` to `dev`, `next` @@ -195,7 +227,9 @@ jobs: - `actions/cloudflare-deploy`: `wrangler deploy` for the environment, with an optional D1 migration first (`migrate-d1`). Other inputs: - `cloudflare-account-id`, `working-directory`, `command`. + `cloudflare-account-id`, `working-directory`, `command`, and + `token-fallback` (warns on every deploy while the repo's token uses the + account-wide fallback, see [`CONTRIBUTING.md`](CONTRIBUTING.md)). - `actions/netlify-deploy`: `prod` is a production deploy, `dev`/`staging` are alias deploys. Inputs: `env`, `publish-dir`, `netlify-auth-token`, `netlify-site-id`, `working-directory`, `github-token`. Turn off Netlify's @@ -343,8 +377,7 @@ Upgrading from `lefthook/site.yml` (v0.13 and earlier): change `configs` to ## Used by [taraxvoid.net](https://taraxvoid.net), [Queer Omaha](https://queeromaha.net), -[Synth Omaha](https://synthomaha.net), [Soundry](https://soundryomaha.org), -[ravenflight.io](https://ravenflight.io). +[Synth Omaha](https://synthomaha.net), [Soundry](https://soundryomaha.org). Optional badge for your README (follows light/dark mode): diff --git a/actions/cloudflare-deploy/action.yml b/actions/cloudflare-deploy/action.yml index 6b1295a..f1b891e 100644 --- a/actions/cloudflare-deploy/action.yml +++ b/actions/cloudflare-deploy/action.yml @@ -30,6 +30,12 @@ inputs: migrate-d1: description: If 'true', run 'bun run db:migrate:' before deploying. default: 'false' + token-fallback: + description: > + Pass vars.CF_TOKEN_FALLBACK. Set by 'just cf-tokens-apply' when the + env's token needed an account-wide fallback; non-empty adds a warning + to every deploy so the wider grant is never silent. + default: '' outputs: deployment-url: @@ -39,6 +45,17 @@ outputs: runs: using: composite steps: + - name: Warn about a fallback token + if: ${{ inputs.token-fallback != '' }} + shell: bash + env: + FALLBACK: ${{ inputs.token-fallback }} + ENV_NAME: ${{ inputs.env }} + run: | + msg="Cloudflare token for $ENV_NAME has an account-wide Workers grant: $FALLBACK. See voidflow cloudflare/tokens.ts." + echo "::warning title=Cloudflare token fallback::$msg" + printf '> [!WARNING]\n> %s\n' "$msg" >> "$GITHUB_STEP_SUMMARY" + - name: Apply D1 migrations if: ${{ inputs.migrate-d1 == 'true' }} shell: bash @@ -79,7 +96,7 @@ runs: RAW_URL: ${{ steps.deploy.outputs.deployment-url }} # wrangler-action's deployment-url is taken verbatim from wrangler's `targets` # list, which for a worker with custom-domain routes reads like - # "ravenflight.io (custom domain)" rather than a URL -- strip that + # "example.com (custom domain)" rather than a URL -- strip that # trailing " (...)" annotation and add a scheme if one isn't already # present, so consumers always get a URL page.goto()/curl can use. run: | diff --git a/actions/netlify-deploy/action.yml b/actions/netlify-deploy/action.yml index 84a5dfd..2ef1bbe 100644 --- a/actions/netlify-deploy/action.yml +++ b/actions/netlify-deploy/action.yml @@ -1,6 +1,6 @@ name: Netlify deploy description: > - Deploys via nwtgck/actions-netlify, honoring the ravenflight main/next/live + Deploys via nwtgck/actions-netlify, honoring the main/next/live branch convention: 'prod' is a real production deploy, 'dev'/'staging' are alias deploys under that environment name. NOTE: this assumes deploys are driven from here, not Netlify's own GitHub App auto-deploy — the two will diff --git a/cloudflare/tokens.ts b/cloudflare/tokens.ts new file mode 100644 index 0000000..0dfa177 --- /dev/null +++ b/cloudflare/tokens.ts @@ -0,0 +1,434 @@ +/** + * Scoped Cloudflare deploy tokens: the config shape and the plan logic. The + * fleet itself (repos, accounts, envs) lives in the org's infra repo and is + * passed with --config (e.g. taraxvoid/infra's cf-tokens.ts). + * + * `scripts/cf-tokens.ts` diffs that config against the account's API tokens and + * (with --apply) creates, updates or rotates them, writing each new value to + * the repo's `CLOUDFLARE_API_TOKEN` secret (per GitHub environment by default, + * or repo-wide). Tokens it did not create are left alone unless a repo lists + * them under `retire` (deleted only with --prune). + * + * The Worker and its zones come from the repo's own wrangler config unless an + * env spells them out, so a route added there shows up as drift here. + * + * Each token is owned by its env's account (envs can live in different + * accounts) and scoped to one Worker (Workers Editor on that + * Worker), plus Workers Routes Write on the zones of its routes. + * Custom Domains do not support per-Worker roles yet; when a deploy proves the + * zone grant is not enough, record a `fallback` and the token also gets + * account-wide Workers Scripts Write. Every plan and every deploy says so. + */ + +export type Fallback = { + /** Why the narrow token was not enough, e.g. 'custom-domain'. */ + reason: string + /** ISO date the fallback was recorded. */ + since: string +} + +export type EnvConfig = { + /** + * Names the token, and is the GitHub environment that holds its secret + * (unless `secret` is 'repo'). + */ + env: string + /** + * Where the secret goes: 'env' (default) is an environment secret, read + * only by jobs with `environment: `, so branch policies on that + * environment gate it; 'repo' is a repository secret every job can read, + * for repos that deploy without environments. One env per repo at most. + */ + secret?: 'env' | 'repo' + /** Worker script name. Default: from the wrangler config. */ + worker?: string + /** + * Zones to grant Workers Routes Write on, by name. Default: the zones of + * the wrangler config's routes; `[]` for none (e.g. a Worker Preview + * token, which deploys no routes). + */ + zones?: string[] + /** Wrangler config path in the repo (default branch). Default: wrangler.jsonc. */ + wrangler?: string + /** + * Wrangler `env.` block to read. Default: the block named like + * `env` if the config has one, else the top level. + */ + wranglerEnv?: string + /** Account the Worker and token live in, when not the repo's `account`. */ + account?: string + fallback?: Fallback +} + +/** What an env's token is scoped to, once resolved. */ +export type Target = { worker: string; zones: string[] } + +/** An old token to retire; a bare name means the repo's `account`. */ +export type Retire = string | { account: string; name: string } + +export type RepoConfig = { + /** Default account for the repo's envs and `retire` names. */ + account: string + envs: EnvConfig[] + /** Pre-voidflow token names to delete with --prune once cut over. */ + retire: Retire[] +} + +export type Resources = Record> + +export type Policy = { + effect: 'allow' + permission_groups: Array<{ id: string; name?: string }> + resources: Resources +} + +/** A token as the account tokens API returns it (the fields we read). */ +export type Token = { + id: string + name: string + status?: string + policies: Array + expires_on?: string | null + last_used_on?: string | null +} + +export const TAG = 'voidflow' +/** Token lifetime; `apply` rotates and `--check` fails inside RENEW_DAYS. */ +export const TTL_DAYS = 90 +export const RENEW_DAYS = 14 + +// Permission group ids. Names are cosmetic and can change; ids are stable. +// `just cf-tokens-groups` prints the live names next to these. +// +// WORKER_EDITOR is the set the dashboard produced for "Specified Workers > +// Editor" (2026-10, twice). It mixes the new Workers role groups with legacy +// ones (KV, R2, Scripts, Account Settings). Shaken down 2026-10 on transbot +// (custom domain, zone grant only) and taraxvoid.net (KV bound by id, two +// zones): both deployed with no fallback. Whether every group is needed is +// untested. +export const WORKER_EDITOR = [ + 'cb142f7fbc3540f0bade0cbe75f606dc', + 'bdbcd690c763475a985e8641dddc09f7', + '82c075da3f4647a2a03becd0fe240f8a', + '2e095cf436e2455fa62c9a9c2e18c478', + '8d28297797f24fb8a0c332fe0866ec89', + 'bf7481a1826f439697cb59a20b22293e', + '05880cd1bdc24d8bae0be2136972816b', + 'f7f0eda5697f475c90846e879bab8666', + 'e086da7e2179491d91ee5f35b3ca210a', + 'c1fde68c7bcc44588cbb6ddbc16d6480', +] +/** Zone > Workers Routes > Write: add or keep routes and custom domains. */ +export const WORKERS_ROUTES_WRITE = '28f4b596e7d643029c524985477ae49a' +/** Legacy account-wide Workers Scripts Write, used only for a fallback. */ +export const WORKERS_SCRIPTS_WRITE = 'e086da7e2179491d91ee5f35b3ca210a' + +export const tokenName = (repo: string, env: string) => + `${TAG}:${repo}:${env}` + +/** The account an env's Worker and token live in. */ +export const envAccount = (cfg: RepoConfig, env: EnvConfig) => + env.account ?? cfg.account + +/** `retire` entries with their accounts filled in. */ +export const retireTargets = (cfg: RepoConfig) => + cfg.retire.map((r) => + typeof r === 'string' ? { account: cfg.account, name: r } : r, + ) + +/** Every account a repo's envs and `retire` entries touch. */ +export const repoAccounts = (cfg: RepoConfig) => [ + ...new Set([ + ...cfg.envs.map((e) => envAccount(cfg, e)), + ...retireTargets(cfg).map((r) => r.account), + ]), +] + +// ---- wrangler config -------------------------------------------------------- + +type Route = string | { pattern: string; zone_name?: string } + +/** + * The Worker name and route hostnames an env deploys, read from a parsed + * wrangler config the way wrangler reads it: an `env.` block's `name`, + * else the top-level name plus `-`; its `routes`/`route`, else the + * top level's. A route's `zone_name`, when set, is used as is. + */ +export function fromWrangler( + config: unknown, + env: string, + wranglerEnv?: string, +): { worker: string; hosts: string[]; zones: string[] } { + type Block = { name?: string; route?: Route; routes?: Route[] } + const top = config as Block & { env?: Record } + const block = wranglerEnv ?? (top.env?.[env] ? env : undefined) + const sub = block === undefined ? undefined : top.env?.[block] + if (block !== undefined && !sub) { + throw new Error(`wrangler config has no env.${block}`) + } + const worker = + sub?.name ?? (top.name && block ? `${top.name}-${block}` : top.name) + if (!worker) throw new Error('wrangler config has no name') + const pick = (b?: Block) => b?.routes ?? (b?.route ? [b.route] : undefined) + const hosts: string[] = [] + const zones: string[] = [] + for (const r of pick(sub) ?? pick(top) ?? []) { + if (typeof r !== 'string' && r.zone_name) zones.push(r.zone_name) + else hosts.push(routeHost(typeof r === 'string' ? r : r.pattern)) + } + return { worker, hosts, zones } +} + +/** `*.example.com/api/*` and `https://example.com:443/` to their hostname. */ +export const routeHost = (pattern: string) => + pattern + .replace(/^[a-z]+:\/\//, '') + .replace(/[/:].*$/, '') + .replace(/^\*\.?/, '') + +/** The zone a hostname belongs to: the longest zone name it ends with. */ +export function zoneOf(host: string, zoneNames: string[]): string | undefined { + return zoneNames + .filter((z) => host === z || host.endsWith(`.${z}`)) + .sort((a, b) => b.length - a.length)[0] +} + +/** + * The Worker and zone names an env's token covers: its own `worker`/`zones` + * when given, else the wrangler config's (`wrangler` is undefined only when + * the env gives both). Throws when a route's host is in none of `zoneNames` + * (the account's zones), rather than leaving it out. + */ +export function resolveTarget( + env: EnvConfig, + wrangler: unknown, + zoneNames: string[], +): Target { + const from = () => fromWrangler(wrangler, env.env, env.wranglerEnv) + const worker = env.worker ?? from().worker + if (env.zones) return { worker, zones: env.zones } + const { hosts, zones } = from() + for (const h of hosts) { + const z = zoneOf(h, zoneNames) + if (!z) throw new Error(`route host ${h} is in no zone of the account`) + zones.push(z) + } + return { worker, zones: [...new Set(zones)].sort() } +} + +// ---- config from elsewhere -------------------------------------------------- + +const ENV_FIELDS = [ + 'env', + 'secret', + 'worker', + 'zones', + 'wrangler', + 'wranglerEnv', + 'account', + 'fallback', +] satisfies Array as string[] + +/** + * Check an untyped config (another repo's `--config` module) and throw with + * every problem, by path. Returns the config unchanged. + */ +export function checkRepos(x: unknown): Record { + const isObj = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v) + const isStr = (v: unknown): v is string => + typeof v === 'string' && v !== '' + const isAcct = (v: unknown) => + typeof v === 'string' && /^[0-9a-f]{32}$/.test(v) + if (!isObj(x)) throw new Error('config is not an object of repos') + const errs: string[] = [] + const bad = (path: string, why: string) => errs.push(`${path}: ${why}`) + for (const [repo, cfg] of Object.entries(x)) { + const at = JSON.stringify(repo) + if (!/^[^/\s]+\/[^/\s]+$/.test(repo)) bad(at, 'key is not owner/name') + if (!isObj(cfg)) { + bad(at, 'not an object') + continue + } + if (!isAcct(cfg.account)) bad(`${at}.account`, 'not a 32-hex account id') + if (!Array.isArray(cfg.envs)) bad(`${at}.envs`, 'not a list') + else if (cfg.envs.length === 0) bad(`${at}.envs`, 'empty') + const seen = new Set() + let repoScoped = 0 + for (const [i, env] of (Array.isArray(cfg.envs) ? cfg.envs : []).entries()) { + const e = `${at}.envs[${i}]` + if (!isObj(env)) { + bad(e, 'not an object') + continue + } + for (const k of Object.keys(env)) + if (!ENV_FIELDS.includes(k)) bad(`${e}.${k}`, 'unknown field') + if (!isStr(env.env)) bad(`${e}.env`, 'missing') + else if (seen.has(env.env)) bad(`${e}.env`, `duplicate "${env.env}"`) + else seen.add(env.env) + if (env.secret !== undefined && env.secret !== 'env' && env.secret !== 'repo') + bad(`${e}.secret`, "'env' or 'repo'") + if (env.secret === 'repo' && ++repoScoped === 2) + bad(`${e}.secret`, "a second 'repo' env would overwrite the first's secret") + for (const k of ['worker', 'wrangler', 'wranglerEnv']) + if (env[k] !== undefined && !isStr(env[k])) bad(`${e}.${k}`, 'not a name') + if ( + env.zones !== undefined && + (!Array.isArray(env.zones) || !env.zones.every(isStr)) + ) + bad(`${e}.zones`, 'not a list of zone names') + if (env.account !== undefined && !isAcct(env.account)) + bad(`${e}.account`, 'not a 32-hex account id') + if ( + env.fallback !== undefined && + !( + isObj(env.fallback) && + isStr(env.fallback.reason) && + isStr(env.fallback.since) + ) + ) + bad(`${e}.fallback`, 'needs reason and since') + } + if (!Array.isArray(cfg.retire)) bad(`${at}.retire`, 'not a list') + for (const [i, r] of (Array.isArray(cfg.retire) ? cfg.retire : []).entries()) { + if (!(isStr(r) || (isObj(r) && isAcct(r.account) && isStr(r.name)))) + bad(`${at}.retire[${i}]`, 'a name or { account, name }') + } + } + if (errs.length) throw new Error(errs.join('\n')) + return x as Record +} + +const account = (id: string) => `com.cloudflare.api.account.${id}` + +/** + * Policies for one env. `scriptTag` is the Worker's tag (not its name) and + * `zoneIds` maps each of the target's zone names to its id; tokens reference + * resources by id, so the script looks both up. + */ +export function policies( + cfg: RepoConfig, + env: EnvConfig, + ids: { scriptTag: string; zoneIds: Record }, + zones: string[] = Object.keys(ids.zoneIds), +): Policy[] { + const acct = account(envAccount(cfg, env)) + const out: Policy[] = [ + { + effect: 'allow', + permission_groups: WORKER_EDITOR.map((id) => ({ id })), + resources: { + [acct]: { + [`com.cloudflare.edge.worker.script.${ids.scriptTag}`]: + '*', + }, + }, + }, + ] + if (zones.length > 0) { + const resources: Resources = {} + for (const z of zones) { + const id = ids.zoneIds[z] + if (!id) throw new Error(`no zone id for ${z}`) + resources[`com.cloudflare.api.account.zone.${id}`] = '*' + } + out.push({ + effect: 'allow', + permission_groups: [{ id: WORKERS_ROUTES_WRITE }], + resources, + }) + } + if (env.fallback) { + out.push({ + effect: 'allow', + permission_groups: [{ id: WORKERS_SCRIPTS_WRITE }], + resources: { [acct]: '*' }, + }) + } + return out +} + +/** Expiry `days` from `now`, at the end of that UTC day (dashboard style). */ +export function expiresOn(now: Date, days = TTL_DAYS): string { + const d = new Date(now.getTime() + days * 86_400_000) + return `${d.toISOString().slice(0, 10)}T23:59:59Z` +} + +// ---- normalisation + plan --------------------------------------------------- + +/** Order-insensitive policies, without the ids and names the API adds. */ +export function normalise(ps: Policy[]): string { + const sortKeys = (r: Resources | string): unknown => + typeof r === 'string' + ? r + : Object.fromEntries( + Object.keys(r) + .sort() + .map((k) => [k, sortKeys(r[k] as Resources | string)]), + ) + return JSON.stringify( + ps + .map((p) => + JSON.stringify({ + effect: p.effect, + permission_groups: p.permission_groups + .map((g) => g.id) + .sort(), + resources: sortKeys(p.resources), + }), + ) + .sort(), + ) +} + +export type Action = + | { kind: 'create'; reason: string } + | { kind: 'update'; token: Token; reason: string } + /** Create a replacement, set the secret, then delete every `tokens`. */ + | { kind: 'rotate'; tokens: Token[]; reason: string } + | { kind: 'ok'; token: Token } + +const daysLeft = (iso: string, now: Date) => + (Date.parse(iso) - now.getTime()) / 86_400_000 + +/** + * What to do for one env's token. `have` is every account token with the + * env's name. More than one means a rotation stopped between creating a token + * and deleting the old one, and nothing says which value the GitHub secret + * holds, so rotate: once the secret has a fresh value, all of them are unused. + */ +export function plan( + want: Policy[], + have: Token[], + now: Date, + opts: { rotate?: boolean } = {}, +): Action[] { + if (have.length === 0) return [{ kind: 'create', reason: 'missing' }] + if (have.length > 1) { + return [ + { + kind: 'rotate', + tokens: have, + reason: `${have.length} tokens (interrupted rotation?)`, + }, + ] + } + const [token] = have + const left = token.expires_on ? daysLeft(token.expires_on, now) : null + if (opts.rotate || left === null || left < RENEW_DAYS) { + // The replacement is created with the wanted policies, so a rotation + // also fixes any drift. + const why = opts.rotate + ? 'requested' + : left === null + ? 'no expiry' + : left < 0 + ? 'expired' + : `expires in ${Math.floor(left)}d` + return [{ kind: 'rotate', tokens: [token], reason: why }] + } + if (normalise(token.policies) !== normalise(want)) { + return [{ kind: 'update', token, reason: 'policies differ' }] + } + return [{ kind: 'ok', token }] +} diff --git a/fnox.toml b/fnox.toml new file mode 100644 index 0000000..d2156c4 --- /dev/null +++ b/fnox.toml @@ -0,0 +1,12 @@ +# Secrets the repo tooling needs, declared without their values (fnox: +# https://github.com/jdx/fnox, installed by mise). `cf-admin` defaults to the +# macOS Keychain; to use another backend (age, a password manager...), redefine +# [providers.cf-admin] in a gitignored fnox.local.toml. See README "Cloudflare +# deploy tokens as code". Nothing here is secret. + +[providers.cf-admin] +type = "keychain" +service = "fnox" + +[secrets] +CF_TOKEN_ADMIN= { provider = "cf-admin", value = "voidflow-cf-token-admin", description = "User-owned Cloudflare token for just cf-tokens-*, covering every account in cloudflare/tokens.ts: Account API Tokens Edit, Workers Scripts Read, Zone Read. Local only, never CI.", if_missing = "error" } diff --git a/jsr.json b/jsr.json index 91ee14a..76078ba 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@taraxvoid/voidflow", - "version": "0.15.1", + "version": "0.16.0-next", "license": "MIT", "exports": { "./playwright": "./playwright/index.mjs" diff --git a/justfile b/justfile index 0612320..e1de8b4 100644 --- a/justfile +++ b/justfile @@ -87,14 +87,43 @@ doctor: @command -v lefthook >/dev/null && echo "lefthook: $(lefthook version)" || echo "lefthook: NOT FOUND (needed to run git hooks - run 'just doctor' then 'lefthook install')" @command -v gitleaks >/dev/null && echo "gitleaks: $(gitleaks version)" || echo "gitleaks: NOT FOUND (needed for 'just lint')" @command -v act >/dev/null && echo "act: $(act --version)" || echo "act: NOT FOUND (needed for local dry-run - not a blocker to commit/push)" + @command -v fnox >/dev/null && echo "fnox: $(fnox --version)" || echo "fnox: NOT FOUND (run 'mise install'; holds CF_TOKEN_ADMIN for 'just cf-tokens-*')" + @command -v fnox >/dev/null && { fnox check >/dev/null 2>&1 && echo "CF_TOKEN_ADMIN: resolvable via fnox" || echo "CF_TOKEN_ADMIN: not resolvable (run 'fnox check'; see README)"; } || true + +# So a relative --config (another repo's fleet map) resolves from where `just` +# was run, not this directory. See scripts/lib/config.ts. +config_base := "env VOIDFLOW_CONFIG_BASE=" + quote(invocation_directory()) # Show how each repo's branch rulesets differ from rulesets/index.ts (read-only) rulesets-plan *args: - bun scripts/apply-rulesets.ts {{ args }} + {{ config_base }} bun scripts/apply-rulesets.ts {{ args }} # Create/update branch rulesets to match rulesets/index.ts (needs repo admin via gh) rulesets-apply *args: - bun scripts/apply-rulesets.ts --apply {{ args }} + {{ config_base }} bun scripts/apply-rulesets.ts --apply {{ args }} + +# CF_TOKEN_ADMIN comes from fnox (fnox.toml) unless already in the environment. +cf_admin := if env_var_or_default("CF_TOKEN_ADMIN", "") == "" { "fnox exec --" } else { "" } +cf_tokens := cf_admin + " " + config_base + " bun scripts/cf-tokens.ts" + +# The cf-tokens-* recipes all take --config : the fleet map in your +# infra repo (e.g. taraxvoid/infra), whose justfile wraps these. + +# Show how each repo's Cloudflare deploy tokens differ from the config (read-only; needs CF_TOKEN_ADMIN and gh) +cf-tokens-plan *args: + {{ cf_tokens }} {{ args }} + +# Create/update/rotate deploy tokens and set each repo's env secret (needs CF_TOKEN_ADMIN and gh) +cf-tokens-apply *args: + {{ cf_tokens }} --apply {{ args }} + +# Print the live names of the permission group ids cloudflare/tokens.ts uses +cf-tokens-groups *args: + {{ cf_tokens }} --groups {{ args }} + +# Run a command from the invoking directory with a throwaway token scoped like 's token, e.g. `cf-tokens-try ../infra/cf-tokens.ts transbot prod bunx wrangler deploy` +cf-tokens-try config repo env +cmd: + {{ cf_tokens }} --config {{ quote(config) }} --repo {{ repo }} --try {{ env }} -- {{ cmd }} # Open the next -> main promotion PR, set to auto-merge with a merge commit promote: diff --git a/mise.toml b/mise.toml index de32161..5a4224e 100644 --- a/mise.toml +++ b/mise.toml @@ -9,3 +9,4 @@ bun = "1.4.3" act = "0.2.89" uv = "0.13.0" gitleaks = "8.30.1" +fnox = "1.42.0" diff --git a/package.json b/package.json index 65746e3..f712bee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@taraxvoid/voidflow", - "version": "0.15.1", + "version": "0.16.0-next", "description": "Enter a flow state of security, stability, and developer ergonomics - batteries-included Biome, vitest, Astro, local hooks, CI and deployments on Netlify and Cloudflare support with GitHub rulesets, branch environments, releases and security checks.", "license": "MIT", "keywords": [ diff --git a/rulesets/index.ts b/rulesets/index.ts index 65224c6..e333575 100644 --- a/rulesets/index.ts +++ b/rulesets/index.ts @@ -8,7 +8,7 @@ export type BypassActor = { actor_id: number | null - actor_type: 'RepositoryRole' | 'Integration' | 'DeployKey' + actor_type: 'RepositoryRole' | 'Integration' | 'DeployKey' | 'OrganizationAdmin' bypass_mode: 'always' | 'pull_request' } @@ -70,6 +70,21 @@ export type RepoConfig = { retire: string[] } +/** + * A repo whose rulesets are spelled out in full instead of built from tiers, + * for fleets whose protection doesn't fit the tier model (see `--config`). + */ +export type RawConfig = { + rulesets: Ruleset[] + /** As in RepoConfig: names to delete with --prune. */ + retire: string[] +} + +export type Fleet = Record + +const isRaw = (cfg: RepoConfig | RawConfig): cfg is RawConfig => + 'rulesets' in cfg + // Reported by both site-ci.yml and tool-ci.yml when the caller job is `validate`. const VALIDATE_CHECK = 'validate / Validate' @@ -121,14 +136,6 @@ export const REPOS: Record = { // Deploys to Cloudflare through voidflow, so no Netlify check and no // deploy app bypass. Private, so skipped until rulesets are available. 'taraxvoid/taraxvoid.net': site({ netlify: false }), - // Trunk-based: one main deployed to Cloudflare through voidflow (PR previews, - // prod environment gate). Its old next/live rulesets are retired at cutover. - 'ravenflight/ravenflight.io': site({ - netlify: false, - codeowners: false, - aka: { main: ['main (dev)'] }, - retire: ['staging (next)', 'prod (live)'], - }), 'taraxvoid/voidflow': { // main (stable), next (prerelease channel), release/* (maintenance). // See RELEASING.md. Promotion next -> main uses merge commits, so no @@ -243,15 +250,91 @@ export function build(tier: Tier, cfg: RepoConfig): Ruleset { } /** Every ruleset we want, per repo, with the names it may already have. */ -export function desired(repo: string): Array<{ ruleset: Ruleset; aka: string[] }> { - const cfg = REPOS[repo] +export function desired( + repo: string, + fleet: Fleet = REPOS, +): Array<{ ruleset: Ruleset; aka: string[] }> { + const cfg = fleet[repo] if (!cfg) throw new Error(`unknown repo ${repo}`) + if (isRaw(cfg)) return cfg.rulesets.map((ruleset) => ({ ruleset, aka: [] })) return cfg.tiers.map((tier) => ({ ruleset: build(tier, cfg), aka: cfg.aka[tier] ?? [], })) } +// ---- config from elsewhere ------------------------------------------------ + +const TIERS: Tier[] = ['main', 'next', 'live', 'prerelease', 'maintenance'] + +/** + * Check an untyped fleet (another repo's `--config` module) and throw with + * every problem, by path. A tiered repo may leave out any field: it gets + * `tool()`'s value, so a field added to RepoConfig later defaults for configs + * written before it. A field that is given must have the default's type, and + * an unknown one (likely a typo) is an error. Returns the filled-in fleet. + */ +export function checkFleet(x: unknown): Fleet { + const isObj = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v) + const isStr = (v: unknown): v is string => typeof v === 'string' && v !== '' + const strList = (v: unknown) => Array.isArray(v) && v.every(isStr) + if (!isObj(x)) throw new Error('config is not an object of repos') + const errs: string[] = [] + const bad = (path: string, why: string) => errs.push(`${path}: ${why}`) + const kind = (v: unknown) => + Array.isArray(v) ? 'list' : isObj(v) ? 'object' : typeof v + const defaults: Record = tool() + const out: Fleet = {} + for (const [repo, cfg] of Object.entries(x)) { + const at = JSON.stringify(repo) + if (!/^[^/\s]+\/[^/\s]+$/.test(repo)) bad(at, 'key is not owner/name') + if (!isObj(cfg)) { + bad(at, 'not an object') + continue + } + if ('rulesets' in cfg) { + out[repo] = cfg as RawConfig + if (!strList(cfg.retire)) bad(`${at}.retire`, 'not a list') + if (!Array.isArray(cfg.rulesets)) { + bad(`${at}.rulesets`, 'not a list') + continue + } + for (const [i, r] of cfg.rulesets.entries()) { + const rs = `${at}.rulesets[${i}]` + if (!isObj(r)) { + bad(rs, 'not an object') + continue + } + if (!isStr(r.name)) bad(`${rs}.name`, 'missing') + if (!strList(r.conditions?.ref_name?.include)) + bad(`${rs}.conditions.ref_name.include`, 'not a list') + if (!Array.isArray(r.bypass_actors)) + bad(`${rs}.bypass_actors`, 'not a list') + if (!Array.isArray(r.rules) || !r.rules.every((u: any) => isStr(u?.type))) + bad(`${rs}.rules`, 'not a list') + } + continue + } + for (const k of Object.keys(cfg)) { + if (!(k in defaults)) bad(`${at}.${k}`, 'unknown field') + } + const full = { ...defaults, ...cfg } + for (const [k, d] of Object.entries(defaults)) { + if (kind(full[k]) !== kind(d)) bad(`${at}.${k}`, `not a ${kind(d)}`) + } + for (const k of ['checks', 'retire']) + if (Array.isArray(full[k]) && !strList(full[k])) + bad(`${at}.${k}`, 'not a list of names') + for (const [i, t] of (Array.isArray(full.tiers) ? full.tiers : []).entries()) { + if (!TIERS.includes(t)) bad(`${at}.tiers[${i}]`, `unknown tier ${JSON.stringify(t)}`) + } + out[repo] = full as RepoConfig + } + if (errs.length) throw new Error(errs.join('\n')) + return out +} + // ---- normalisation + diff ------------------------------------------------- /** Order-insensitive canonical form so GitHub's ordering never shows as drift. */ diff --git a/scripts/apply-rulesets.ts b/scripts/apply-rulesets.ts index 1e03378..b7d08c8 100755 --- a/scripts/apply-rulesets.ts +++ b/scripts/apply-rulesets.ts @@ -7,6 +7,11 @@ * bun scripts/apply-rulesets.ts --check # plan, exit 1 on drift (for CI) * bun scripts/apply-rulesets.ts --apply # create/update to match * bun scripts/apply-rulesets.ts --apply --prune # ...and delete retired rulesets + * bun scripts/apply-rulesets.ts --config ../infra/rulesets.ts # another fleet's config + * + * The config is REPOS in rulesets/index.ts unless --config names a module + * exporting `REPOS` (or a default) of the same shape, where a repo may also + * spell out its rulesets in full (see RawConfig and scripts/lib/config.ts). * * Uses the `gh` CLI for auth, so run it as someone with admin on the repos. * Rulesets not described in the config are reported and left alone; only the @@ -15,7 +20,16 @@ * and reported, never counted as drift, so --check stays green for them. */ import { parseArgs } from 'node:util' -import { REPOS, desired, diff, isPlanGated, normalise, type Ruleset } from '../rulesets/index.ts' +import { loadOrExit } from './lib/config.ts' +import { + REPOS as BUILTIN, + checkFleet, + desired, + diff, + isPlanGated, + normalise, + type Ruleset, +} from '../rulesets/index.ts' const { values } = parseArgs({ options: { @@ -23,9 +37,12 @@ const { values } = parseArgs({ prune: { type: 'boolean', default: false }, check: { type: 'boolean', default: false }, repo: { type: 'string' }, + config: { type: 'string' }, }, }) +const REPOS = await loadOrExit(values.config, BUILTIN, checkFleet) + async function gh(args: string[], body?: unknown): Promise { const proc = Bun.spawn(['gh', 'api', ...args], { stdin: body === undefined ? 'ignore' : new Blob([JSON.stringify(body)]), @@ -65,7 +82,7 @@ for (const repo of repos) { } const matched = new Set() - for (const { ruleset, aka } of desired(repo)) { + for (const { ruleset, aka } of desired(repo, REPOS)) { const hit = listed.find( (r) => r.name === ruleset.name || aka.includes(r.name), ) diff --git a/scripts/cf-tokens.ts b/scripts/cf-tokens.ts new file mode 100644 index 0000000..2506d4c --- /dev/null +++ b/scripts/cf-tokens.ts @@ -0,0 +1,496 @@ +#!/usr/bin/env bun +/** + * Plan, apply or rotate scoped Cloudflare deploy tokens for the fleet in the + * --config module (the org's infra repo), shaped as in cloudflare/tokens.ts. + * Every invocation takes --config , e.g. ../infra/cf-tokens.ts: + * + * bun scripts/cf-tokens.ts # plan (read-only), every repo + * bun scripts/cf-tokens.ts --repo transbot # plan one repo + * bun scripts/cf-tokens.ts --check # plan, exit 1 on drift or a token due to rotate + * bun scripts/cf-tokens.ts --apply # create/update/rotate, set the GitHub env secrets + * bun scripts/cf-tokens.ts --apply --rotate # ...and rotate every token now + * bun scripts/cf-tokens.ts --apply --prune # ...and delete the repo's `retire` tokens + * bun scripts/cf-tokens.ts --groups # live names of the permission group ids used + * bun scripts/cf-tokens.ts --repo transbot --try prod -- bunx wrangler deploy + * # run a command with a throwaway token scoped like prod's + * + * The module exports `REPOS` (or a default); see scripts/lib/config.ts. + * + * Needs CF_TOKEN_ADMIN: a user-owned token covering every account in the + * config (envs can sit in different accounts), + * with Account API Tokens Edit, Workers Scripts Read (Worker tags) and Zone + * Read (zone ids) on each. Keep it local; never put it in CI. + * + * GitHub goes through the `gh` CLI, with admin on the repos: even a plan reads + * each repo's wrangler config (for envs that don't spell out worker and zones) + * and its repo-wide secret names. New token values are piped straight into + * `gh secret set` and never printed. + */ +import { parseArgs } from 'node:util' +import { loadOrExit } from './lib/config.ts' +import { + WORKERS_ROUTES_WRITE, + checkRepos, + WORKERS_SCRIPTS_WRITE, + WORKER_EDITOR, + envAccount, + expiresOn, + plan, + policies, + repoAccounts, + resolveTarget, + retireTargets, + tokenName, + type Action, + type EnvConfig, + type Policy, + type RepoConfig, + type Token, +} from '../cloudflare/tokens.ts' + +const { values, positionals } = parseArgs({ + options: { + apply: { type: 'boolean', default: false }, + rotate: { type: 'boolean', default: false }, + prune: { type: 'boolean', default: false }, + check: { type: 'boolean', default: false }, + groups: { type: 'boolean', default: false }, + try: { type: 'string' }, + repo: { type: 'string' }, + config: { type: 'string' }, + }, + allowPositionals: true, +}) + +const REPOS = await loadOrExit(values.config, undefined, checkRepos) + +const ADMIN = process.env.CF_TOKEN_ADMIN +if (!ADMIN) { + console.error('CF_TOKEN_ADMIN is not set (see the header of this script)') + process.exit(2) +} + +const API = 'https://api.cloudflare.com/client/v4' + +async function cfRaw( + method: string, + path: string, + body?: unknown, +): Promise<{ result: T; result_info?: { total_pages?: number; total_count?: number } }> { + const res = await fetch(`${API}${path}`, { + method, + headers: { + Authorization: `Bearer ${ADMIN}`, + 'Content-Type': 'application/json', + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const json = (await res.json()) as { + success: boolean + errors?: Array<{ code: number; message: string }> + result: T + result_info?: { total_pages?: number; total_count?: number } + } + if (!json.success) { + const why = (json.errors ?? []).map((e) => `${e.code} ${e.message}`) + throw new Error(`${method} ${path}: ${res.status} ${why.join('; ')}`) + } + return json +} + +async function cf(method: string, path: string, body?: unknown): Promise { + return (await cfRaw(method, path, body)).result +} + +/** + * Every page of a list endpoint, going by `result_info` (total_pages, else + * total_count). Without either the endpoint is taken as unpaged: one that + * ignores `page` would return its whole list on every page, forever. + */ +async function cfAll(path: string): Promise { + const out: T[] = [] + for (let page = 1; ; page++) { + const sep = path.includes('?') ? '&' : '?' + const { result, result_info } = await cfRaw( + 'GET', + `${path}${sep}per_page=50&page=${page}`, + ) + out.push(...result) + const info = result_info ?? {} + const done = + info.total_pages !== undefined + ? page >= info.total_pages + : info.total_count !== undefined + ? out.length >= info.total_count || result.length === 0 + : true + if (done) return out + } +} + +async function gh(args: string[], stdin?: string): Promise { + const proc = Bun.spawn(['gh', ...args], { + stdin: stdin === undefined ? 'ignore' : new Blob([stdin]), + stdout: 'pipe', + stderr: 'pipe', + }) + const [out, err, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (code !== 0) throw new Error(`gh ${args.join(' ')}: ${err || out}`) + return out +} + +// ---- --groups --------------------------------------------------------------- + +if (values.groups) { + const accounts = [...new Set(Object.values(REPOS).flatMap(repoAccounts))] + const used = new Map() + const note = (id: string, where: string) => + used.set(id, [...(used.get(id) ?? []), where]) + for (const id of WORKER_EDITOR) note(id, 'WORKER_EDITOR') + note(WORKERS_ROUTES_WRITE, 'WORKERS_ROUTES_WRITE') + note(WORKERS_SCRIPTS_WRITE, 'WORKERS_SCRIPTS_WRITE') + for (const acct of accounts) { + const live = await cf< + Array<{ id: string; name: string; scopes?: string[] }> + >('GET', `/accounts/${acct}/tokens/permission_groups`) + const byId = new Map(live.map((g) => [g.id, g])) + console.log(`== account ${acct}`) + for (const [id, where] of used) { + const g = byId.get(id) + const desc = g + ? `${g.name} [${(g.scopes ?? []).join(',')}]` + : 'NOT FOUND' + console.log(` ${id} ${desc} (${where.join(', ')})`) + } + } + process.exit(0) +} + +// ---- plan / apply ----------------------------------------------------------- + +const repos = Object.keys(REPOS).filter( + (r) => !values.repo || r === values.repo || r.endsWith(`/${values.repo}`), +) +if (repos.length === 0) { + console.error(`no repo matches --repo ${values.repo}`) + process.exit(2) +} + +const now = new Date() +let pending = 0 +let failed = 0 + +/** Per-account lists every env in that account shares, fetched once. */ +const memo = (load: (key: string) => Promise) => { + const seen = new Map>() + return (key: string) => { + if (!seen.has(key)) seen.set(key, load(key)) + return seen.get(key) as Promise + } +} +const scriptsIn = memo((acct) => + cfAll<{ id: string; tag: string }>(`/accounts/${acct}/workers/scripts`), +) +const zonesIn = memo((acct) => + cfAll<{ id: string; name: string }>(`/zones?account.id=${acct}`), +) +/** A repo's wrangler config on its default branch, parsed. Key: repo:path. */ +const wranglerAt = memo(async (key) => { + const [repo, path] = key.split(':') + const text = await gh([ + 'api', + `repos/${repo}/contents/${path}`, + '-H', + 'Accept: application/vnd.github.raw', + ]) + return path.endsWith('.toml') ? Bun.TOML.parse(text) : Bun.JSONC.parse(text) +}) + +/** The env's Worker and zones, and the ids its policies reference them by. */ +async function lookups(repo: string, acct: string, env: EnvConfig) { + const zones = await zonesIn(acct) + const wrangler = + env.worker && env.zones + ? undefined + : await wranglerAt(`${repo}:${env.wrangler ?? 'wrangler.jsonc'}`) + const t = resolveTarget( + env, + wrangler, + zones.map((z) => z.name), + ) + const script = (await scriptsIn(acct)).find((s) => s.id === t.worker) + if (!script) { + // Per-Worker grants apply only to Workers that exist; creating one + // needs product-level Workers Admin, which no deploy token gets. + throw new Error( + `Worker "${t.worker}" does not exist in account ${acct} yet: deploy it once with an admin token, then re-run`, + ) + } + const zoneIds: Record = {} + for (const name of t.zones) { + const zone = zones.find((z) => z.name === name) + if (!zone) throw new Error(`zone ${name} not found in account ${acct}`) + zoneIds[name] = zone.id + } + return { target: t, ids: { scriptTag: script.tag, zoneIds } } +} + +/** `gh` flags for where an env's secret and variable live. */ +const scope = (repo: string, env: EnvConfig) => + env.secret === 'repo' ? ['-R', repo] : ['--env', env.env, '-R', repo] + +const scopeLabel = (repo: string, env: EnvConfig) => + env.secret === 'repo' ? `${repo} (repo-wide)` : `${repo} env ${env.env}` + +async function setSecret(repo: string, env: EnvConfig, value: string) { + await gh(['secret', 'set', 'CLOUDFLARE_API_TOKEN', ...scope(repo, env)], value) +} + +/** Mirror the config's fallback into the variable the deploy action reads. */ +async function syncFallbackVar(repo: string, env: EnvConfig) { + if (env.fallback) { + const body = `${env.fallback.reason} (since ${env.fallback.since})` + await gh([ + 'variable', + 'set', + 'CF_TOKEN_FALLBACK', + ...scope(repo, env), + '--body', + body, + ]) + } else { + // Not set is the normal case: nothing to delete. + await gh(['variable', 'delete', 'CF_TOKEN_FALLBACK', ...scope(repo, env)]).catch( + () => {}, + ) + } +} + +/** + * Mint a token and put its value in the env's secret. If the secret can't be + * set, delete the new token again, so a token with this name only outlives + * the call once GitHub holds it. + */ +async function createAndSet( + repo: string, + acct: string, + env: EnvConfig, + name: string, + want: Policy[], +) { + const t = await cf( + 'POST', + `/accounts/${acct}/tokens`, + { name, policies: want, expires_on: expiresOn(now) }, + ) + try { + await setSecret(repo, env, t.value) + } catch (e) { + await cf('DELETE', `/accounts/${acct}/tokens/${t.id}`).catch( + (d: Error) => + console.error( + ` x could not delete ${t.id} after the failed secret write; the next run rotates it away: ${d.message}`, + ), + ) + throw e + } + return t +} + +async function act( + repo: string, + acct: string, + env: EnvConfig, + name: string, + want: Policy[], + a: Action, +) { + const label = `${env.env} "${name}"` + if (a.kind === 'ok') { + const used = a.token.last_used_on ?? 'never' + console.log(` ok ${label}, expires ${a.token.expires_on}, last used ${used}`) + return + } + pending++ + const sign = { create: '+', update: '~', rotate: '@' }[a.kind] + console.log(`${sign} ${a.kind} ${label}: ${a.reason}`) + if (!values.apply) return + const path = `/accounts/${acct}/tokens` + switch (a.kind) { + case 'create': { + const t = await createAndSet(repo, acct, env, name, want) + console.log(` created ${t.id}, secret set in ${scopeLabel(repo, env)}`) + return + } + case 'update': + await cf('PUT', `${path}/${a.token.id}`, { + name, + policies: want, + expires_on: a.token.expires_on, + status: 'active', + }) + console.log(' updated (value unchanged)') + return + case 'rotate': { + // New token, then the secret, then delete the old ones, so a + // deploy in between still has a working token. A stop before the + // deletes leaves duplicates, which the next plan rotates away. + const t = await createAndSet(repo, acct, env, name, want) + for (const old of a.tokens) await cf('DELETE', `${path}/${old.id}`) + const ids = a.tokens.map((o) => o.id).join(', ') + console.log(` rotated ${ids} to ${t.id}, secret set`) + return + } + } +} + +async function run( + repo: string, + cfg: RepoConfig, + env: EnvConfig, + tokens: Map, +) { + const acct = envAccount(cfg, env) + const name = tokenName(repo, env.env) + const { target, ids } = await lookups(repo, acct, env) + const want = policies(cfg, env, ids, target.zones) + const zones = target.zones.length ? target.zones.join(', ') : 'no zones' + console.log(` ${env.env}: Worker ${target.worker}, ${zones}, secret in ${scopeLabel(repo, env)}`) + // The list endpoint omits policies; fetch each match in full. + const have = await Promise.all( + (tokens.get(acct) ?? []) + .filter((t) => t.name === name) + .map((t) => cf('GET', `/accounts/${acct}/tokens/${t.id}`)), + ) + if (env.fallback) { + console.log( + ` ! ${env.env}: FALLBACK account-wide Workers Scripts Write: ${env.fallback.reason} (since ${env.fallback.since})`, + ) + } + for (const a of plan(want, have, now, { rotate: values.rotate })) { + await act(repo, acct, env, name, want, a) + } + if (values.apply) await syncFallbackVar(repo, env) +} + +// ---- --try ------------------------------------------------------------------ + +/** + * Run a command (a deploy, say) with a throwaway token that has exactly the + * env's policies, to prove they are enough before CI depends on them. The + * real token's value only exists in GitHub, so a local deploy can't use it, + * and a shell holding a wider token would hide a missing grant. The child gets + * the throwaway token as CLOUDFLARE_API_TOKEN, with CF_TOKEN_ADMIN removed, + * runs in the directory `just` was run from, and the token is deleted after + * (it expires within the hour regardless). + */ +async function tryEnv(envName: string, cmd: string[]) { + if (repos.length !== 1 || cmd.length === 0) { + console.error('--try needs one --repo, an env, and a command after --') + process.exit(2) + } + const [repo] = repos + const cfg = REPOS[repo] + const env = cfg.envs.find((e) => e.env === envName) + if (!env) { + console.error(`${repo} has no env ${envName}`) + process.exit(2) + } + const acct = envAccount(cfg, env) + const { target, ids } = await lookups(repo, acct, env) + const expires = new Date(now.getTime() + 3_600_000) + const t = await cf('POST', `/accounts/${acct}/tokens`, { + name: `${tokenName(repo, env.env)}:try`, + policies: policies(cfg, env, ids, target.zones), + expires_on: `${expires.toISOString().slice(0, 19)}Z`, + }) + const zones = target.zones.length ? target.zones.join(', ') : 'no zones' + console.log(`try ${t.id}: Worker ${target.worker}, ${zones}, until ${expires.toISOString()}`) + let code = 1 + try { + const childEnv: Record = { + ...process.env, + CLOUDFLARE_API_TOKEN: t.value, + CLOUDFLARE_ACCOUNT_ID: acct, + } + delete childEnv.CF_TOKEN_ADMIN + const proc = Bun.spawn(cmd, { + cwd: process.env.VOIDFLOW_CONFIG_BASE || process.cwd(), + env: childEnv, + stdio: ['inherit', 'inherit', 'inherit'], + }) + code = await proc.exited + } finally { + await cf('DELETE', `/accounts/${acct}/tokens/${t.id}`) + console.log(`try ${t.id} deleted; the command exited ${code}`) + } + process.exit(code) +} + +if (values.try !== undefined) await tryEnv(values.try, positionals) + +for (const repo of repos) { + const cfg = REPOS[repo] + console.log(`\n== ${repo}`) + const tokens = new Map() + for (const acct of repoAccounts(cfg)) { + tokens.set(acct, await cfAll(`/accounts/${acct}/tokens`)) + } + let envsOk = true + for (const env of cfg.envs) { + try { + await run(repo, cfg, env, tokens) + } catch (e) { + envsOk = false + failed++ + console.error(` x ${env.env}: ${(e as Error).message}`) + } + } + // Retire old tokens (and a shadowed repo secret) only once every env, in + // every account, has its replacement. + const note = !values.prune + ? ' (needs --prune)' + : envsOk + ? '' + : ' (skipped: an env failed above)' + const prune = values.apply && values.prune && envsOk + const old = retireTargets(cfg).flatMap(({ account, name }) => + (tokens.get(account) ?? []) + .filter((t) => t.name === name) + .map((t) => ({ account, t })), + ) + for (const { account, t } of old) { + pending++ + console.log(`- retire "${t.name}" (${t.id})${note}`) + if (prune) { + await cf('DELETE', `/accounts/${account}/tokens/${t.id}`) + console.log(' deleted') + } + } + // With every env's secret in an environment, a repo-wide one is a + // pre-voidflow leftover: environment jobs never read it, and any job + // without an environment that still does is deploying with an old token. + if (!cfg.envs.some((e) => e.secret === 'repo')) { + const repoSecrets = await gh(['secret', 'list', '-R', repo, '--json', 'name', '-q', '.[].name']) + if (repoSecrets.split('\n').includes('CLOUDFLARE_API_TOKEN')) { + pending++ + console.log( + `- repo-wide secret CLOUDFLARE_API_TOKEN: every env's is in an environment; jobs without one still read this${note}`, + ) + if (prune) { + await gh(['secret', 'delete', 'CLOUDFLARE_API_TOKEN', '-R', repo]) + console.log(' deleted') + } + } + } +} + +const summary = + pending === 0 + ? 'in sync' + : `${pending} change(s) ${values.apply ? 'applied' : 'pending (re-run with --apply)'}` +console.log(`\n${summary}${failed ? `, ${failed} env(s) failed` : ''}`) +if (failed > 0 || (values.check && pending > 0)) process.exit(1) diff --git a/scripts/lib/config.ts b/scripts/lib/config.ts new file mode 100644 index 0000000..1540be6 --- /dev/null +++ b/scripts/lib/config.ts @@ -0,0 +1,52 @@ +/** + * Fleet config for the repo tools (cf-tokens, apply-rulesets): a module in + * the org's own (private) infra repo, given with --config, which holds the + * repo, account and zone mapping so voidflow holds only code. apply-rulesets + * still has a built-in fleet until that moves out too. + */ +import { resolve } from 'node:path' + +/** + * The `REPOS` (or default) export of the module at `path`, resolved against + * `base` and run through `check`, which throws listing every problem. + * Without a `path`, `builtin` if the tool has one, else an error. + */ +export async function loadConfig( + path: string | undefined, + base: string, + builtin: T | undefined, + check: (x: unknown) => T, +): Promise { + if (!path) { + if (builtin !== undefined) return builtin + throw new Error( + 'needs --config : the fleet map in your infra repo (see CONTRIBUTING.md)', + ) + } + const file = resolve(base, path) + const mod = (await import(file)) as { REPOS?: unknown; default?: unknown } + const cfg = mod.REPOS ?? mod.default + if (cfg === undefined) throw new Error(`${file}: no REPOS or default export`) + try { + return check(cfg) + } catch (e) { + throw new Error(`${file}: ${(e as Error).message}`) + } +} + +/** + * Load the config named by --config, exiting 2 with the problems on failure. + * Relative paths resolve against VOIDFLOW_CONFIG_BASE (the just recipes set + * it to the directory `just` was run from), else the current directory. + */ +export function loadOrExit( + path: string | undefined, + builtin: T | undefined, + check: (x: unknown) => T, +): Promise { + const base = process.env.VOIDFLOW_CONFIG_BASE || process.cwd() + return loadConfig(path, base, builtin, check).catch((e: Error) => { + console.error(e.message) + process.exit(2) + }) +} diff --git a/test/cf-tokens.test.ts b/test/cf-tokens.test.ts new file mode 100644 index 0000000..a2f0224 --- /dev/null +++ b/test/cf-tokens.test.ts @@ -0,0 +1,386 @@ +import { describe, expect, test } from 'bun:test' +import { + checkRepos, + fromWrangler, + resolveTarget, + routeHost, + zoneOf, + RENEW_DAYS, + WORKERS_ROUTES_WRITE, + WORKERS_SCRIPTS_WRITE, + expiresOn, + plan, + policies, + repoAccounts, + retireTargets, + tokenName, + type RepoConfig, + type Policy, + type Token, +} from '../cloudflare/tokens.ts' + +// A fleet like taraxvoid/infra's cf-tokens.ts (the real one lives there). +const REPOS: Record = { + 'taraxvoid/transbot': { + account: 'c6a445a43a801403a8e33968be40c19c', + envs: [{ env: 'prod' }], + retire: ['transbot-deploy-token'], + }, + 'taraxvoid/taraxvoid.net': { + account: 'c6a445a43a801403a8e33968be40c19c', + envs: [{ env: 'prod' }, { env: 'preview', zones: [] }], + retire: [], + }, +} +const transbot = REPOS['taraxvoid/transbot'] +const prod = transbot.envs[0] +const ids = { scriptTag: 'tag123', zoneIds: { 'transbot.net': 'zone456' } } +const acct = `com.cloudflare.api.account.${transbot.account}` +const now = new Date('2026-10-15T12:00:00Z') + +const token = (over: Partial = {}): Token => ({ + id: 't1', + name: tokenName('taraxvoid/transbot', 'prod'), + policies: policies(transbot, prod, ids), + expires_on: expiresOn(now), + ...over, +}) + +describe('policies', () => { + test('scope the editor groups to the one Worker by tag', () => { + const [worker] = policies(transbot, prod, ids) + expect(worker.resources).toEqual({ + [acct]: { 'com.cloudflare.edge.worker.script.tag123': '*' }, + }) + }) + + test('grant routes write on the configured zones only, never zone.*', () => { + const zone = policies(transbot, prod, ids)[1] + expect(zone.permission_groups).toEqual([{ id: WORKERS_ROUTES_WRITE }]) + // Same shape the dashboard produces for "Specific zone". + expect(zone.resources).toEqual({ + 'com.cloudflare.api.account.zone.zone456': '*', + }) + expect(JSON.stringify(zone)).not.toContain('zone.*') + }) + + test('no account-wide grant unless a fallback is recorded', () => { + const wide = (ps: Policy[]) => + ps.filter((p) => p.resources[acct] === '*') + expect(wide(policies(transbot, prod, ids))).toEqual([]) + const fb = wide( + policies( + transbot, + { + ...prod, + fallback: { reason: 'custom-domain', since: '2026-10-15' }, + }, + ids, + ), + ) + expect(fb.map((p) => p.permission_groups)).toEqual([ + [{ id: WORKERS_SCRIPTS_WRITE }], + ]) + }) + + test('a zone without an id fails rather than widening', () => { + expect(() => + policies(transbot, prod, { scriptTag: 't', zoneIds: {} }, [ + 'transbot.net', + ]), + ).toThrow('transbot.net') + }) + + test('every env has a unique token name', () => { + const names = Object.entries(REPOS).flatMap(([repo, cfg]) => + cfg.envs.map((e) => tokenName(repo, e.env)), + ) + expect(new Set(names).size).toBe(names.length) + }) +}) + +describe('accounts', () => { + // dev/staging in one account, prod in another. + const split: RepoConfig = { + account: 'proddd', + envs: [ + { env: 'dev', worker: 'api-dev', zones: [], account: 'devvv' }, + { env: 'prod', worker: 'api', zones: [] }, + ], + retire: ['old-prod', { account: 'devvv', name: 'old-dev' }], + } + const ids0 = { scriptTag: 'tag', zoneIds: {} } + + test("an env's policies name its own account, else the repo's", () => { + const [dev, prod] = split.envs.map( + (e) => Object.keys(policies(split, e, ids0)[0].resources)[0], + ) + expect(dev).toBe('com.cloudflare.api.account.devvv') + expect(prod).toBe('com.cloudflare.api.account.proddd') + }) + + test("a fallback stays inside the env's account", () => { + const dev = { + ...split.envs[0], + fallback: { reason: 'custom-domain', since: '2026-10-15' }, + } + const wide = policies(split, dev, ids0).at(-1) + expect(wide?.resources).toEqual({ + 'com.cloudflare.api.account.devvv': '*', + }) + }) + + test("bare retire names resolve to the repo's account", () => { + expect(retireTargets(split)).toEqual([ + { account: 'proddd', name: 'old-prod' }, + { account: 'devvv', name: 'old-dev' }, + ]) + }) + + test('a repo touches each account once', () => { + expect(repoAccounts(split).sort()).toEqual(['devvv', 'proddd']) + }) +}) + +describe('expiresOn', () => { + test('ends the UTC day TTL days out', () => { + expect(expiresOn(now, 90)).toBe('2027-01-13T23:59:59Z') + }) +}) + +describe('plan', () => { + const want = policies(transbot, prod, ids) + + test('creates a missing token', () => { + expect(plan(want, [], now).map((a) => a.kind)).toEqual(['create']) + }) + + test('is ok when only order and API-added fields differ', () => { + const shuffled = token({ + policies: [...want].reverse().map((p, i) => ({ + ...p, + id: `p${i}`, + permission_groups: [...p.permission_groups] + .reverse() + .map((g) => ({ ...g, name: 'cosmetic' })), + })), + }) + expect(plan(want, [shuffled], now).map((a) => a.kind)).toEqual(['ok']) + }) + + test('updates in place when policies drift', () => { + const narrower = token({ policies: [want[0]] }) + expect(plan(want, [narrower], now).map((a) => a.kind)).toEqual([ + 'update', + ]) + }) + + test('rotates inside the renewal window, when expired, or without expiry', () => { + const at = (days: number) => + new Date(now.getTime() + days * 86_400_000).toISOString() + for (const expires_on of [at(RENEW_DAYS - 1), at(-1), null]) { + const [a] = plan(want, [token({ expires_on })], now) + expect(a.kind).toBe('rotate') + } + const later = token({ expires_on: at(RENEW_DAYS + 1) }) + expect(plan(want, [later], now)[0].kind).toBe('ok') + }) + + test('rotates on request', () => { + expect(plan(want, [token()], now, { rotate: true })[0].kind).toBe( + 'rotate', + ) + }) + + test('a rotation replaces the token it found', () => { + const [a] = plan(want, [token({ id: 'old' })], now, { rotate: true }) + expect(a.kind === 'rotate' && a.tokens.map((t) => t.id)).toEqual(['old']) + }) + + // Either duplicate may be the one in the GitHub secret (a stop between + // create and secret write leaves the newer one unused), so neither is + // kept: a fresh token goes into the secret, then both are deleted. + test('rotates away duplicates instead of guessing which one is live', () => { + const old = token({ id: 'old', expires_on: expiresOn(now, 30) }) + const fresh = token({ id: 'new' }) + const acts = plan(want, [old, fresh], now) + expect(acts).toHaveLength(1) + const [a] = acts + expect(a.kind).toBe('rotate') + expect(a.kind === 'rotate' && a.tokens.map((t) => t.id)).toEqual([ + 'old', + 'new', + ]) + }) +}) + +describe('checkRepos', () => { + const DEV = '189861042e47aa72c52e44fe4d18e723' + const PROD = 'e632b9f816f9f2a62ed9571d352fd4ae' + const good = { + 'example-org/site': { + account: PROD, + envs: [ + { env: 'dev', worker: 'site-dev', zones: [], account: DEV }, + { env: 'prod', worker: 'site', zones: ['example.com'] }, + ], + retire: ['old', { account: DEV, name: 'old-dev' }], + }, + } + + test('accepts a fleet and a split-account one', () => { + expect(checkRepos(REPOS)).toBe(REPOS) + expect(checkRepos(good)).toBe(good) + }) + + test('names every problem by its path', () => { + const bad = { + nope: { account: PROD, envs: [], retire: [] }, + 'a/b': { + account: 'not-hex', + envs: [ + { + env: 'prod', + worker: 3, + zones: 'example.com', + account: 'x', + secret: 'org', + wranglr: 'wrangler.toml', + }, + { + env: 'prod', + worker: 'w', + zones: [], + fallback: { reason: 'custom-domain' }, + }, + { env: 'a', secret: 'repo' }, + { env: 'b', secret: 'repo' }, + ], + retire: [{ name: 'old' }], + }, + } + let msg = '' + try { + checkRepos(bad) + } catch (e) { + msg = (e as Error).message + } + for (const want of [ + '"nope": key is not owner/name', + '"nope".envs: empty', + '"a/b".account: not a 32-hex account id', + '"a/b".envs[0].worker: not a name', + '"a/b".envs[0].secret: \'env\' or \'repo\'', + '"a/b".envs[0].wranglr: unknown field', + '"a/b".envs[3].secret: a second \'repo\' env', + '"a/b".envs[0].zones: not a list of zone names', + '"a/b".envs[0].account: not a 32-hex account id', + '"a/b".envs[1].env: duplicate "prod"', + '"a/b".envs[1].fallback: needs reason and since', + '"a/b".retire[0]: a name or { account, name }', + ]) { + expect(msg).toContain(want) + } + }) + + test('rejects a config that is not an object of repos', () => { + expect(() => checkRepos(undefined)).toThrow('not an object') + expect(() => checkRepos([])).toThrow('not an object') + }) +}) + + +describe('wrangler config', () => { + // Shaped like transbot's real config. + const single = { + name: 'transbot', + routes: [{ pattern: 'transbot.net', custom_domain: true }], + } + const multi = { + name: 'site', + routes: ['old.example.com/*'], + env: { + preview: { name: 'site-preview', workers_dev: true }, + prod: { + name: 'site-prod', + routes: [ + { pattern: 'example.com', custom_domain: true }, + { pattern: 'www.example.com', custom_domain: true }, + { pattern: 'dl.example.com', custom_domain: true }, + { pattern: 'other.biz', custom_domain: true }, + { pattern: 'api.example.net/*', zone_name: 'example.net' }, + ], + }, + staging: {}, + }, + } + const zones = ['example.com', 'other.biz', 'transbot.net', 'sub.other.biz'] + + test('routeHost strips scheme, path, port and a leading wildcard', () => { + expect(routeHost('*.example.com/api/*')).toBe('example.com') + expect(routeHost('*example.com/*')).toBe('example.com') + expect(routeHost('https://a.example.com:443/')).toBe('a.example.com') + expect(routeHost('example.com')).toBe('example.com') + }) + + test('zoneOf picks the longest zone the host is in, never a lookalike', () => { + expect(zoneOf('www.example.com', zones)).toBe('example.com') + expect(zoneOf('a.sub.other.biz', zones)).toBe('sub.other.biz') + expect(zoneOf('notexample.com', zones)).toBeUndefined() + }) + + test('top level when there is no env block named like the env', () => { + expect(fromWrangler(single, 'prod')).toEqual({ + worker: 'transbot', + hosts: ['transbot.net'], + zones: [], + }) + }) + + test("an env block's name and routes, and a route's zone_name as is", () => { + const t = fromWrangler(multi, 'prod') + expect(t.worker).toBe('site-prod') + expect(t.hosts).toEqual([ + 'example.com', + 'www.example.com', + 'dl.example.com', + 'other.biz', + ]) + expect(t.zones).toEqual(['example.net']) + }) + + test("like wrangler: no name is -, no routes inherits the top level's", () => { + expect(fromWrangler(multi, 'staging')).toEqual({ + worker: 'site-staging', + hosts: ['old.example.com'], + zones: [], + }) + }) + + test('wranglerEnv picks a block, and a missing one fails', () => { + expect(fromWrangler(multi, 'live', 'prod').worker).toBe('site-prod') + expect(() => fromWrangler(multi, 'prod', 'nope')).toThrow('env.nope') + }) + + test('resolveTarget maps routes to deduplicated account zones', () => { + expect(resolveTarget({ env: 'prod' }, multi, zones)).toEqual({ + worker: 'site-prod', + zones: ['example.com', 'example.net', 'other.biz'], + }) + }) + + test('a route outside every account zone fails instead of being dropped', () => { + expect(() => + resolveTarget({ env: 'prod' }, single, ['example.com']), + ).toThrow('transbot.net') + }) + + test("the env's own worker and zones win; zones: [] grants none", () => { + expect( + resolveTarget({ env: 'preview', zones: [] }, single, zones), + ).toEqual({ worker: 'transbot', zones: [] }) + expect( + resolveTarget({ env: 'x', worker: 'w', zones: ['a.org'] }, undefined, []), + ).toEqual({ worker: 'w', zones: ['a.org'] }) + }) +}) diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..42ef3b6 --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from 'bun:test' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { loadConfig } from '../scripts/lib/config.ts' + +describe('loadConfig', () => { + const dir = mkdtempSync(join(tmpdir(), 'voidflow-config-')) + const cfg = { 'example-org/site': { n: 1 } } + const builtin = { 'taraxvoid/x': { n: 0 } } + // Accepts objects whose values all have a numeric `n`. + const check = (x: unknown) => { + const ok = + typeof x === 'object' && + x !== null && + Object.values(x).every((v) => typeof (v as any)?.n === 'number') + if (!ok) throw new Error('"x": n is not a number') + return x as typeof cfg + } + writeFileSync(join(dir, 'named.ts'), `export const REPOS = ${JSON.stringify(cfg)}`) + writeFileSync(join(dir, 'default.ts'), `export default ${JSON.stringify(cfg)}`) + writeFileSync(join(dir, 'empty.ts'), 'export const other = 1') + writeFileSync(join(dir, 'bad.ts'), "export const REPOS = { x: { n: 'one' } }") + + test('without a path, the built-in config, unchecked', async () => { + expect(await loadConfig(undefined, dir, builtin, check)).toBe(builtin) + }) + + test('without a path or a built-in, asks for --config', async () => { + await expect( + loadConfig(undefined, dir, undefined, check), + ).rejects.toThrow('needs --config') + }) + + test('a REPOS or default export, relative to the base dir', async () => { + expect(await loadConfig('named.ts', dir, builtin, check)).toEqual(cfg) + expect( + await loadConfig(join(dir, 'default.ts'), '/', builtin, check), + ).toEqual(cfg) + }) + + test('names the file when it has no config or a bad one', async () => { + await expect( + loadConfig('empty.ts', dir, builtin, check), + ).rejects.toThrow(`${join(dir, 'empty.ts')}: no REPOS or default export`) + await expect(loadConfig('bad.ts', dir, builtin, check)).rejects.toThrow( + `${join(dir, 'bad.ts')}: "x": n is not a number`, + ) + }) +}) diff --git a/test/package-meta.test.ts b/test/package-meta.test.ts index a4fda33..43e1a10 100644 --- a/test/package-meta.test.ts +++ b/test/package-meta.test.ts @@ -6,6 +6,7 @@ const pkg = JSON.parse(read('package.json')) describe('package.json', () => { test('has good keywords', () => { + expect(pkg.keywords).not.toContain('astro') for (const k of ['lefthook', 'renovate', 'bun']) expect(pkg.keywords).toContain(k) }) diff --git a/test/rulesets.test.ts b/test/rulesets.test.ts index 8a98f12..b9b9fc2 100644 --- a/test/rulesets.test.ts +++ b/test/rulesets.test.ts @@ -1,5 +1,17 @@ import { describe, expect, test } from 'bun:test' -import { NETLIFY, REPOS, build, desired, diff, isPlanGated, normalise, tool } from '../rulesets/index.ts' +import { + NETLIFY, + REPOS, + build, + checkFleet, + desired, + diff, + isPlanGated, + normalise, + tool, + type Fleet, + type Ruleset, +} from '../rulesets/index.ts' const statusChecks = (r: ReturnType) => ( @@ -57,7 +69,7 @@ describe('canonical rulesets', () => { describe('normalise', () => { test('ignores the dismissal_restriction default that org-owned repos echo back', () => { - const want = build('main', REPOS['ravenflight/ravenflight.io']) + const want = build('main', REPOS['taraxvoid/queeromaha']) const have = structuredClone(want) const pr = have.rules.find((x) => x.type === 'pull_request') if (pr?.parameters) pr.parameters.dismissal_restriction = { enabled: false, allowed_actors: [] } @@ -148,6 +160,97 @@ describe('voidflow release channels', () => { }) }) +describe('raw rulesets (another fleet, --config)', () => { + const live: Ruleset = { + name: 'prod (live)', + target: 'branch', + enforcement: 'active', + conditions: { + ref_name: { + include: ['refs/heads/live', 'refs/heads/promote/*'], + exclude: [], + }, + }, + bypass_actors: [ + { actor_id: null, actor_type: 'OrganizationAdmin', bypass_mode: 'always' }, + ], + rules: [ + { type: 'deletion' }, + { + type: 'required_deployments', + parameters: { required_deployment_environments: ['staging'] }, + }, + ], + } + const fleet: Fleet = { + 'example-org/site': { rulesets: [live], retire: ['old'] }, + 'taraxvoid/voidflow': REPOS['taraxvoid/voidflow'], + } + + test('are wanted verbatim, never adopted under another name', () => { + expect(desired('example-org/site', fleet)).toEqual([ + { ruleset: live, aka: [] }, + ]) + }) + + test('tiered repos in the same fleet still build from tiers', () => { + expect(desired('taraxvoid/voidflow', fleet)).toEqual( + desired('taraxvoid/voidflow'), + ) + }) + + test("show no drift against GitHub's copy, extra fields and order aside", () => { + const fromApi = { + ...live, + id: 1, + source: 'example-org/site', + node_id: 'x', + rules: [...live.rules].reverse(), + } + expect(diff(normalise(fromApi), normalise(live))).toEqual([]) + }) + + test('checkFleet accepts the built-in config and raw rulesets', () => { + expect(checkFleet(REPOS)).toEqual(REPOS) + expect(checkFleet(fleet)).toEqual(fleet) + }) + + test("checkFleet fills a tiered repo's missing fields from tool()", () => { + expect(checkFleet({ 'a/b': {} })['a/b']).toEqual(tool()) + expect(checkFleet({ 'a/b': { netlify: true } })['a/b']).toEqual( + tool({ netlify: true }), + ) + }) + + test('checkFleet names every problem by its path', () => { + let msg = '' + try { + checkFleet({ + nope: { rulesets: [], retire: [] }, + 'a/b': { rulesets: [{ name: '', rules: {} }] }, + 'a/c': { ...REPOS['taraxvoid/voidflow'], tiers: ['main', 'dev'] }, + 'a/d': { checks: 'validate', codeowners: 'yes' }, + 'a/e': { linearhistory: false }, + }) + } catch (e) { + msg = (e as Error).message + } + for (const want of [ + '"nope": key is not owner/name', + '"a/b".rulesets[0].name: missing', + '"a/b".rulesets[0].conditions.ref_name.include: not a list', + '"a/b".rulesets[0].rules: not a list', + '"a/b".retire: not a list', + '"a/c".tiers[1]: unknown tier "dev"', + '"a/d".checks: not a list', + '"a/d".codeowners: not a boolean', + '"a/e".linearhistory: unknown field', + ]) { + expect(msg).toContain(want) + } + }) +}) + describe('tool()', () => { const cfg = tool() diff --git a/test/site-ci.test.ts b/test/site-ci.test.ts index c92de70..81f0f28 100644 --- a/test/site-ci.test.ts +++ b/test/site-ci.test.ts @@ -24,9 +24,12 @@ describe('site-ci.yml job shape', () => { expect(validateStep('Secrets Scan')).toBeDefined() }) + // The trailing SARIF steps are the exception: they belong to the scan, so a + // docs-only PR still keeps its gitleaks and zizmor SARIF (see tool-ci.test.ts). test('every step after decide is gated on it, so a docs-only PR stops after the scan', () => { const ids = validate.steps.map((s: any) => s.id) - const after = validate.steps.slice(ids.indexOf('decide') + 1) + const after = validate.steps.slice(ids.indexOf('decide') + 1, -3) + expect(validate.steps.slice(-3).map((s: any) => s.name)).toEqual(['Setup uv (SARIF)', 'zizmor SARIF', 'Keep SARIF']) expect(after.length).toBeGreaterThan(10) for (const s of after) expect(s.if).toContain(GATE) }) diff --git a/test/tool-ci.test.ts b/test/tool-ci.test.ts index 8cad8a5..853d1e1 100644 --- a/test/tool-ci.test.ts +++ b/test/tool-ci.test.ts @@ -97,3 +97,59 @@ describe('tool-ci.yml script steps', () => { } }) }) + +describe('SARIF for code scanning', () => { + const sarifSteps = ['Setup uv (SARIF)', 'zizmor SARIF', 'Keep SARIF'] + + test.each(sarifSteps)('"%s" is identical to site-ci and runs after failures', (name) => { + expect(step(name)).toEqual(siteStep(name)) + expect(step(name).if).toStartWith('${{ !cancelled()') + }) + + test('the SARIF steps come last, after every gate', () => { + expect(steps.slice(-3).map((s) => s.name)).toEqual(sarifSteps) + expect(siteSteps.slice(-3).map((s) => s.name)).toEqual(sarifSteps) + }) + + test('gitleaks writes SARIF but still gates', () => { + const run: string = step('Secrets Scan').run + expect(run).toContain('--report-format sarif --report-path "$RUNNER_TEMP/sarif/gitleaks.sarif"') + expect(step('Secrets Scan').if).toBeUndefined() + expect(step('Secrets Scan')['continue-on-error']).toBeUndefined() + }) + + test('zizmor SARIF uses the justfile pin and scans all workflows', () => { + const pinned = read('justfile').match(/^zizmor_version := "([^"]+)"/m)![1] + expect(step('zizmor SARIF').run).toContain(`uvx zizmor@${pinned} --format sarif .github > "$RUNNER_TEMP/sarif/zizmor.sarif"`) + expect(step('zizmor SARIF').if).not.toContain('steps.changes') + }) + + test('kept as the voidflow-sarif artifact', () => { + expect(step('Keep SARIF').with.name).toBe('voidflow-sarif') + }) +}) + +describe('upload-sarif.yml', () => { + const up: any = Bun.YAML.parse(read('.github/workflows/upload-sarif.yml')) + const upSteps: any[] = up.jobs.upload.steps + + test('is reusable only and is the one workflow asking for security-events', () => { + expect(Object.keys(up.on)).toEqual(['workflow_call']) + expect(up.permissions).toEqual({ contents: 'read', actions: 'read', 'security-events': 'write' }) + for (const f of ['site-ci.yml', 'tool-ci.yml']) { + const w: any = Bun.YAML.parse(read(`.github/workflows/${f}`)) + expect(JSON.stringify(w)).not.toContain('security-events') + } + }) + + test('reads the artifact the validate workflows keep', () => { + expect(upSteps[0].with.name).toBe(step('Keep SARIF').with.name) + expect(upSteps[0]['continue-on-error']).toBe(true) + }) + + test('one upload per tool, categorized by tool name', () => { + const uploads = upSteps.filter((s) => /upload-sarif@/.test(s.uses ?? '')) + expect(uploads.map((s) => s.with.category)).toEqual(['gitleaks', 'zizmor']) + for (const s of upSteps.filter((s) => s.uses)) expect(s.uses).toMatch(/@[0-9a-f]{40}$/) + }) +})