Skip to content

Commit dacd638

Browse files
committed
ci: restrict Bazel workflow token permissions
1 parent 88afdf4 commit dacd638

4 files changed

Lines changed: 53 additions & 1 deletion

File tree

.github/scripts/test_verify_required_ci.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ def setUpClass(cls) -> None:
2626
cls.blob_size_policy_workflow = Path(
2727
".github/workflows/blob-size-policy.yml"
2828
).read_text(encoding="utf-8")
29+
cls.bazel_workflow = Path(".github/workflows/bazel.yml").read_text(
30+
encoding="utf-8"
31+
)
2932
cls.python_runtime_build_workflow = Path(
3033
".github/workflows/python-runtime-build.yml"
3134
).read_text(encoding="utf-8")
@@ -161,6 +164,38 @@ def test_blob_size_policy_broader_permissions_are_rejected(self) -> None:
161164
verify_required_ci.blob_size_policy_contract_errors(broken),
162165
)
163166

167+
def test_bazel_permissions_are_least_privilege(self) -> None:
168+
self.assertEqual([], verify_required_ci.bazel_contract_errors(self.bazel_workflow))
169+
170+
def test_missing_bazel_token_permissions_are_rejected(self) -> None:
171+
broken = self.bazel_workflow.replace(
172+
"permissions:\n contents: read\n\n", "", 1
173+
)
174+
self.assertIn(
175+
"Bazel CI token permissions must be exactly contents: read",
176+
verify_required_ci.bazel_contract_errors(broken),
177+
)
178+
179+
def test_broader_bazel_token_permissions_are_rejected(self) -> None:
180+
broken = self.bazel_workflow.replace(
181+
"permissions:\n contents: read",
182+
"permissions:\n contents: read\n actions: write",
183+
1,
184+
)
185+
self.assertIn(
186+
"Bazel CI token permissions must be exactly contents: read",
187+
verify_required_ci.bazel_contract_errors(broken),
188+
)
189+
190+
def test_nested_bazel_token_permissions_are_rejected(self) -> None:
191+
broken = self.bazel_workflow.replace(
192+
"permissions:\n contents: read\n\n", "", 1
193+
).replace(" test:\n", " test:\n permissions:\n contents: read\n", 1)
194+
self.assertIn(
195+
"Bazel CI token permissions must be exactly contents: read",
196+
verify_required_ci.bazel_contract_errors(broken),
197+
)
198+
164199
def test_blob_size_policy_least_privilege_is_documented(self) -> None:
165200
threat_model = Path("docs/security/threat-model.md").read_text(encoding="utf-8")
166201
self.assertIn(

.github/scripts/verify_required_ci.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,13 @@ def blob_size_policy_contract_errors(workflow: str) -> list[str]:
124124
return []
125125

126126

127+
def bazel_contract_errors(workflow: str) -> list[str]:
128+
"""Return violations of the Bazel CI least-privilege token contract."""
129+
if _top_level_permissions(workflow) != {("contents", "read")}:
130+
return ["Bazel CI token permissions must be exactly contents: read"]
131+
return []
132+
133+
127134
def python_runtime_build_contract_errors(workflow: str) -> list[str]:
128135
"""Return violations of the Python runtime build token contract."""
129136
if _top_level_permissions(workflow) != {("contents", "read")}:
@@ -195,6 +202,11 @@ def main() -> int:
195202
type=Path,
196203
default=Path(".github/workflows/blob-size-policy.yml"),
197204
)
205+
parser.add_argument(
206+
"--bazel-workflow",
207+
type=Path,
208+
default=Path(".github/workflows/bazel.yml"),
209+
)
198210
parser.add_argument(
199211
"--python-runtime-build-workflow",
200212
type=Path,
@@ -227,6 +239,7 @@ def main() -> int:
227239
args.blob_size_policy_workflow.read_text(encoding="utf-8")
228240
)
229241
)
242+
errors.extend(bazel_contract_errors(args.bazel_workflow.read_text(encoding="utf-8")))
230243
errors.extend(
231244
python_runtime_build_contract_errors(
232245
args.python_runtime_build_workflow.read_text(encoding="utf-8")

.github/workflows/bazel.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ concurrency:
1616
group: concurrency-group::${{ github.workflow }}::${{ github.event.pull_request.number > 0 && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}${{ github.ref_name == 'main' && format('::{0}', github.run_id) || ''}}
1717
cancel-in-progress: ${{ github.ref_name != 'main' }}
1818

19+
# Bazel CI only reads the repository and uploads non-fatal build diagnostics.
20+
permissions:
21+
contents: read
22+
1923
jobs:
2024
test:
2125
# PRs use the sharded Windows cross-compiled test jobs below. Post-merge

docs/security/threat-model.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ S7-3 adds the 90-day CI gate.
9696
| **R — Repudiation** | low | Workflow authorship or the artifact source is ambiguous | Git commit and Actions run logs identify the executed revision; the pinned release fallback verifies GitHub's published SHA-256 digest and embedded package identity for every npm asset | ci-ops | 2026-07-18 |
9797
| **I — Info disclosure** | high | Workflow logs or PR-controlled code leak a release credential | `.github/workflows/ci.yml` grants its repository-scoped `github.token` only `contents: read`. Its release fallback downloads exact public URLs with no token or secret | security | 2026-07-18 |
9898
| **D — DoS** | med | PRs trigger expensive multi-platform jobs or downloads of stale artifacts | The npm workflow has a concurrency group and ten-minute timeout. Current CI also uses macOS and Windows runners, so runner cost is not Linux-only | infra | 2026-07-18 |
99-
| **E — Elevation** | high | A workflow inherits broad repository permissions or a compromised cross-repository token | Repository workflow permissions currently default to `write`; 14 of 26 workflow files now declare a top-level `permissions:` block, including `ci.yml`, `rust-ci.yml`, `rust-ci-full.yml`, `rust-ci-full-nextest-platform.yml`, `sdk.yml`, `blob-size-policy.yml`, `python-runtime-build.yml`, `v8-canary.yml`, and `trufflehog.yml` with only `contents: read`. The built-in token remains limited to this repository and cannot authorize upstream artifact downloads | ci-ops | 2026-07-18 |
99+
| **E — Elevation** | high | A workflow inherits broad repository permissions or a compromised cross-repository token | Repository workflow permissions currently default to `write`; 15 of 26 workflow files now declare a top-level `permissions:` block, including `ci.yml`, `rust-ci.yml`, `rust-ci-full.yml`, `rust-ci-full-nextest-platform.yml`, `sdk.yml`, `blob-size-policy.yml`, `bazel.yml`, `python-runtime-build.yml`, `v8-canary.yml`, and `trufflehog.yml` with only `contents: read`. The built-in token remains limited to this repository and cannot authorize upstream artifact downloads | ci-ops | 2026-07-18 |
100100

101101
### npm staging boundary (PR #605)
102102

0 commit comments

Comments
 (0)