diff --git a/.github/workflows/api-latency-smoke.yml b/.github/workflows/api-latency-smoke.yml index b13205fa78..f978db4b9b 100644 --- a/.github/workflows/api-latency-smoke.yml +++ b/.github/workflows/api-latency-smoke.yml @@ -14,6 +14,7 @@ on: # Least privilege: this workflow only builds a wheel and probes a local # dashboard. It never writes to the repository or any GitHub API surface. +# Any job needing more must elevate with its own job-level `permissions:`. permissions: contents: read @@ -21,11 +22,6 @@ concurrency: group: api-latency-smoke-${{ github.ref }} cancel-in-progress: true -# Least privilege: these jobs only read the repo. Any job needing more -# must elevate with its own job-level `permissions:` block. -permissions: - contents: read - jobs: smoke: name: ${{ matrix.source == 'pypi' && 'PyPI nightly' || 'PR build' }} diff --git a/.github/workflows/auto-deploy-cloud.yml b/.github/workflows/auto-deploy-cloud.yml index 28d3580f35..f402fea9fe 100644 --- a/.github/workflows/auto-deploy-cloud.yml +++ b/.github/workflows/auto-deploy-cloud.yml @@ -20,6 +20,15 @@ on: types: [completed] workflow_dispatch: +# Every cross-repo action below (checkout of clawmetry-cloud, branch push, PR +# create/close/merge) authenticates as `secrets.CLOUD_REPO_PAT`, which this +# block does not govern — a PAT carries its own scopes. The only thing that +# rides on GITHUB_TOKEN is the checkout of THIS repo, which needs `contents: +# read` and nothing more. Deliberately kept read-only so a future step cannot +# quietly acquire write on clawmetry via the ambient token. +permissions: + contents: read + jobs: pin-version: name: Rebuild cloud with latest OSS diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 975fefab8c..bac4b551f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,13 @@ on: branches: [main, master] pull_request: +# Every job here only reads the repository: checkout, install, run tests, +# upload an artifact. Nothing calls the GitHub API with GITHUB_TOKEN, pushes +# a commit, tags, comments on a PR or touches a Release, so the whole +# workflow runs on the read-only default and no job needs an elevated block. +permissions: + contents: read + concurrency: group: ci-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true diff --git a/.github/workflows/desktop-artifacts.yml b/.github/workflows/desktop-artifacts.yml index d92fa897b2..8d62c9a0e6 100644 --- a/.github/workflows/desktop-artifacts.yml +++ b/.github/workflows/desktop-artifacts.yml @@ -18,6 +18,15 @@ on: - 'v*.*.*' workflow_dispatch: {} +# Least-privilege floor for the three BUILD jobs (macos / linux inherit this; +# windows re-declares it alongside id-token). The `release` job, and only that +# job, elevates to `contents: write` so softprops/action-gh-release can attach +# installers to the tag's Release and publish it. Keep the elevation on that +# job — a workflow-wide `contents: write` would hand every build job, and each +# third-party action it runs, the ability to write to the repository. +permissions: + contents: read + jobs: macos: name: macOS .app + .dmg (signed + notarized when secrets are set) diff --git a/.github/workflows/release-on-merge.yml b/.github/workflows/release-on-merge.yml index b2a2303f00..81b3ec1627 100644 --- a/.github/workflows/release-on-merge.yml +++ b/.github/workflows/release-on-merge.yml @@ -5,6 +5,15 @@ on: types: [closed] branches: [main] +# Read-only floor. This workflow's single job re-declares its own scopes +# below, and a job-level block REPLACES the top level rather than merging +# with it, so this value never actually applies to `release` — it is here so +# the default for any job added later is read-only, and so the workflow +# stops reading as "inherits whatever the repository default happens to be". +# The publish/tag/PR scopes stay on the job that needs them, not up here. +permissions: + contents: read + jobs: release: name: Bump version & publish to PyPI diff --git a/tests/test_workflow_yaml_valid.py b/tests/test_workflow_yaml_valid.py index 8b1a6e646f..0347a2d191 100644 --- a/tests/test_workflow_yaml_valid.py +++ b/tests/test_workflow_yaml_valid.py @@ -79,6 +79,58 @@ def test_workflow_parses_as_yaml(path: str) -> None: ) +class _DuplicateKeyLoader(yaml.SafeLoader): + """A SafeLoader that refuses a mapping with a repeated key. + + PyYAML's own resolution is last-one-wins, silently: ``yaml.safe_load`` on a + file with two top-level ``permissions:`` blocks returns a perfectly good + dict and raises nothing. GitHub Actions does the opposite -- it rejects the + file outright and fails the run at startup -- so a duplicate key is exactly + the class of break ``test_workflow_parses_as_yaml`` cannot see. + """ + + +def _no_duplicate_keys(loader, node, deep=False): + seen = set() + for key_node, _ in node.value: + key = loader.construct_object(key_node, deep=deep) + if key in seen: + raise yaml.YAMLError( + f"duplicate key {key!r} at line {key_node.start_mark.line + 1}" + ) + seen.add(key) + return yaml.SafeLoader.construct_mapping(loader, node, deep=deep) + + +_DuplicateKeyLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _no_duplicate_keys +) + + +@pytest.mark.parametrize("path", _workflow_files(), ids=os.path.basename) +def test_workflow_has_no_duplicate_keys(path: str) -> None: + """No mapping may repeat a key. GitHub rejects the file if one does. + + Burned by the token-permissions hardening pass: two separate PRs each + added a top-level ``permissions: contents: read`` block to + api-latency-smoke.yml. Both were individually correct, neither conflicted + in git, and the pre-merge check was ``yaml.safe_load`` -- which happily + kept the second and returned a valid dict. The workflow was dead on main + from the moment the second one merged, its runs listed under the file path + with zero jobs, and the API-latency safety net was off. + """ + with open(path, encoding="utf-8") as fh: + source = fh.read() + try: + yaml.load(source, _DuplicateKeyLoader) + except yaml.YAMLError as exc: # pragma: no cover - message is the point + pytest.fail( + f"{os.path.basename(path)} repeats a mapping key ({exc}). PyYAML " + "keeps the last one without complaining, but GitHub rejects the " + "workflow and fails every run at startup before any step executes." + ) + + @pytest.mark.parametrize("path", _workflow_files(), ids=os.path.basename) def test_workflow_has_required_top_level_keys(path: str) -> None: """A parseable file can still be a non-workflow. Check the shape."""