Enforce workflow data trust boundaries

Contract-Neutral: Moves workflow expressions into environment data flow without changing deployment interfaces or behavior.
This commit is contained in:
pulse-triage[bot] 2026-08-30 04:51:54 +01:00
parent a2bfadba7b
commit d8986c139a
12 changed files with 195 additions and 44 deletions

View file

@ -31,7 +31,9 @@ class WorkflowTrustTest(unittest.TestCase):
def test_accepts_immutable_dependencies_and_explicit_checkout_credentials(self) -> None:
findings = self.audit(
f"""jobs:
f"""permissions:
contents: read
jobs:
test:
runs-on: ubuntu-24.04
steps:
@ -47,7 +49,9 @@ class WorkflowTrustTest(unittest.TestCase):
def test_rejects_mutable_action_runner_and_container_references(self) -> None:
findings = self.audit(
"""jobs:
"""permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
@ -61,14 +65,16 @@ class WorkflowTrustTest(unittest.TestCase):
def test_rejects_implicit_or_unjustified_checkout_credentials(self) -> None:
omitted = self.audit(
f"""steps:
f"""permissions: {{}}
steps:
- uses: actions/checkout@{PIN}
"""
)
self.assertTrue(any("must set persist-credentials" in finding for finding in omitted))
unjustified = self.audit(
f"""steps:
f"""permissions: {{}}
steps:
- uses: actions/checkout@{PIN}
with:
persist-credentials: true
@ -77,7 +83,8 @@ class WorkflowTrustTest(unittest.TestCase):
self.assertTrue(any("require # required" in finding for finding in unjustified))
mixed_case = self.audit(
f"""steps:
f"""permissions: {{}}
steps:
- uses: Actions/Checkout@{PIN}
"""
)
@ -87,7 +94,8 @@ class WorkflowTrustTest(unittest.TestCase):
def test_accepts_documented_authenticated_git_write(self) -> None:
findings = self.audit(
f"""steps:
f"""permissions: {{}}
steps:
- uses: actions/checkout@{PIN}
with:
persist-credentials: true # required: authenticated git writes
@ -95,6 +103,38 @@ class WorkflowTrustTest(unittest.TestCase):
)
self.assertEqual(findings, [])
def test_requires_explicit_least_privilege_permissions(self) -> None:
missing = self.audit("jobs: {}\n")
self.assertTrue(any("top-level permissions" in finding for finding in missing))
broad = self.audit("permissions: write-all\njobs: {}\n")
self.assertTrue(any("scope mapping" in finding for finding in broad))
dynamic = self.audit("permissions: ${{ inputs.permissions }}\njobs: {}\n")
self.assertTrue(any("scope mapping" in finding for finding in dynamic))
def test_rejects_shell_template_data_but_accepts_env_data(self) -> None:
findings = self.audit(
"""permissions: {}
jobs:
unsafe:
runs-on: ubuntu-24.04
steps:
- run: echo "${{ inputs.name }}"
- run: |
echo "${{ secrets.ACCESS_TOKEN }}"
echo "${{ github.token }}"
- env:
NAME: ${{ inputs.name }}
TOKEN: ${{ github.token }}
run: printf '%s\\n' "$NAME" >/dev/null
"""
)
self.assertEqual(
sum("must enter run scripts through env" in finding for finding in findings),
3,
)
def test_repository_workflows_satisfy_contract(self) -> None:
findings = workflow_trust.audit_directory(REPO_ROOT / ".github" / "workflows")
self.assertEqual([finding.render() for finding in findings], [])