Skip to content

Commit 7614cfc

Browse files
committed
ci: restrict Rust workflow token permissions
1 parent 3d690bc commit 7614cfc

4 files changed

Lines changed: 59 additions & 19 deletions

File tree

.github/scripts/test_verify_required_ci.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,39 @@ def setUpClass(cls) -> None:
2121
def test_repository_workflow_satisfies_contract(self) -> None:
2222
self.assertEqual([], verify_required_ci.contract_errors(self.workflow))
2323

24+
def test_missing_rust_ci_token_permissions_are_rejected(self) -> None:
25+
broken = self.workflow.replace(
26+
"permissions:\n contents: read\n\n", "", 1
27+
)
28+
self.assertIn(
29+
"Rust CI token permissions must be exactly contents: read",
30+
verify_required_ci.contract_errors(broken),
31+
)
32+
33+
def test_broader_rust_ci_token_permissions_are_rejected(self) -> None:
34+
for replacement in (
35+
"permissions:\n contents: write",
36+
"permissions:\n contents: read\n actions: read",
37+
):
38+
with self.subTest(replacement=replacement):
39+
broken = self.workflow.replace(
40+
"permissions:\n contents: read", replacement, 1
41+
)
42+
self.assertIn(
43+
"Rust CI token permissions must be exactly contents: read",
44+
verify_required_ci.contract_errors(broken),
45+
)
46+
47+
def test_rust_ci_least_privilege_is_documented(self) -> None:
48+
threat_model = Path("docs/security/threat-model.md").read_text(
49+
encoding="utf-8"
50+
)
51+
self.assertIn("permissions:\n contents: read", self.workflow)
52+
self.assertIn(
53+
"`rust-ci.yml` limits mutable pull-request jobs to `contents: read`",
54+
threat_model,
55+
)
56+
2457
def test_missing_mandatory_dependency_is_rejected(self) -> None:
2558
broken = self.workflow.replace(" workspace,\n", "", 1)
2659
self.assertIn(

.github/scripts/verify_required_ci.py

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,20 @@
1717
)
1818

1919

20+
def _top_level_permissions(workflow: str) -> set[tuple[str, str]]:
21+
permissions_match = re.search(
22+
r"(?ms)^permissions:\s*\n((?:^[ \t]+.*\n?)+)", workflow
23+
)
24+
if permissions_match is None:
25+
return set()
26+
return set(
27+
re.findall(
28+
r"(?m)^\s+([a-z-]+):\s*(read|write|none)\s*(?:#.*)?$",
29+
permissions_match.group(1),
30+
)
31+
)
32+
33+
2034
def _job_block(workflow: str, job: str) -> str:
2135
match = re.search(
2236
rf"(?ms)^ {re.escape(job)}:\s*\n(.*?)(?=^ [A-Za-z0-9_-]+:\s*\n|\Z)",
@@ -29,17 +43,20 @@ def _job_block(workflow: str, job: str) -> str:
2943

3044
def contract_errors(workflow: str) -> list[str]:
3145
"""Return violations of the required-check aggregation contract."""
46+
errors: list[str] = []
47+
if _top_level_permissions(workflow) != {("contents", "read")}:
48+
errors.append("Rust CI token permissions must be exactly contents: read")
49+
3250
try:
3351
results = _job_block(workflow, "results")
3452
except ValueError as error:
35-
return [str(error)]
53+
return [*errors, str(error)]
3654

3755
needs_match = re.search(r"(?ms)^ needs:\s*(.*?)(?=^ [A-Za-z0-9_-]+:|\Z)", results)
3856
if needs_match is None:
39-
return ["results job has no needs dependency list"]
57+
return [*errors, "results job has no needs dependency list"]
4058

4159
needs = set(re.findall(r"\b[A-Za-z][A-Za-z0-9_]*\b", needs_match.group(1)))
42-
errors: list[str] = []
4360
for job in (*REQUIRED_JOBS, *CONDITIONAL_JOBS):
4461
if job not in needs:
4562
errors.append(f"results job does not depend on {job}")
@@ -57,20 +74,7 @@ def contract_errors(workflow: str) -> list[str]:
5774
def npm_contract_errors(workflow: str) -> list[str]:
5875
"""Return violations that can hide an npm staging failure or lock drift."""
5976
errors: list[str] = []
60-
permissions_match = re.search(
61-
r"(?ms)^permissions:\s*\n((?:^[ \t]+.*\n?)+)", workflow
62-
)
63-
declared_permissions = (
64-
set(
65-
re.findall(
66-
r"(?m)^\s+([a-z-]+):\s*(read|write|none)\s*(?:#.*)?$",
67-
permissions_match.group(1),
68-
)
69-
)
70-
if permissions_match is not None
71-
else set()
72-
)
73-
if declared_permissions != {("contents", "read")}:
77+
if _top_level_permissions(workflow) != {("contents", "read")}:
7478
errors.append("npm CI token permissions must be exactly contents: read")
7579

7680
if "pnpm install --frozen-lockfile" not in workflow:

.github/workflows/rust-ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ on:
88
branches: [main]
99
workflow_dispatch:
1010

11+
permissions:
12+
contents: read
13+
1114
concurrency:
1215
group: ${{ github.workflow }}-${{ github.ref }}
1316
cancel-in-progress: true

docs/security/threat-model.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,11 @@ S7-3 adds the 90-day CI gate.
9292
| Threat | Rating | Specific attack vector | Mitigation | Owner | Last reviewed |
9393
|--------|--------|------------------------|------------|-------|---------------|
9494
| **S — Spoofing** | med | Compromised third-party GitHub Action runs attacker code in CI | Critical actions in `.github/workflows/ci.yml` are commit-pinned; repository-wide pin coverage remains a review item rather than an assumed invariant | ci-ops | 2026-07-18 |
95-
| **T — Tampering** | high | A pull request changes staging code and exfiltrates a cross-repository release credential | Pull-request staging executes the checked-out PR merge ref. Never expose an upstream PAT or GitHub App key directly to this mutable job; move authenticated staging behind a trusted workflow boundary first | ci-ops | 2026-07-18 |
95+
| **T — Tampering** | high | A pull request changes staging or build code and abuses the workflow token | Pull-request staging executes the checked-out PR merge ref. Never expose an upstream PAT or GitHub App key directly to this mutable job; move authenticated staging behind a trusted workflow boundary first. `rust-ci.yml` limits mutable pull-request jobs to `contents: read` | ci-ops | 2026-07-18 |
9696
| **R — Repudiation** | low | Workflow authorship or the artifact source is ambiguous | Git commit and Actions run logs identify the executed revision; staging must additionally retain upstream run, artifact ID, and digest evidence | 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`; no upstream `Actions: read` credential is configured. This keeps the credential boundary closed but leaves staging proper red | 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`; 6 of 26 workflow files now declare a top-level `permissions:` block, including `ci.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`; 7 of 26 workflow files now declare a top-level `permissions:` block, including `ci.yml` and `rust-ci.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)