Enforce least-privilege job tokens

This commit is contained in:
pulse-triage[bot] 2026-08-30 22:07:35 +01:00
parent 1d9e66b24c
commit 47c1cee895
3 changed files with 63 additions and 18 deletions

View file

@ -44,6 +44,7 @@ SECRET_CONTEXT_TOKEN_RE = re.compile(r"(?<![\w.])secrets\b")
NON_CONFIDENTIAL_PULL_REQUEST_SECRETS = frozenset({"PULSE_LICENSE_PUBLIC_KEY"})
CHECKOUT_PREFIX = "actions/checkout@"
WRITE_CREDENTIAL_RATIONALE = "# required: authenticated git writes"
PERMISSIONS_RE = re.compile(r"^(\s*)permissions\s*:\s*(.*?)\s*$")
@dataclass(frozen=True)
@ -154,11 +155,16 @@ def audit_workflow(path: Path) -> list[Finding]:
)
permission_declarations = [
index
(index, match)
for index, line in enumerate(lines)
if re.match(r"^permissions\s*:", line)
if (match := PERMISSIONS_RE.match(line.split("#", 1)[0]))
]
if len(permission_declarations) != 1:
top_level_permissions = [
(index, match)
for index, match in permission_declarations
if not match.group(1)
]
if len(top_level_permissions) != 1:
findings.append(
Finding(
path,
@ -166,17 +172,20 @@ def audit_workflow(path: Path) -> list[Finding]:
"workflow must declare top-level permissions explicitly exactly once",
)
)
elif lines[permission_declarations[0]].split("#", 1)[0].strip() not in {
"permissions:",
"permissions: {}",
}:
findings.append(
Finding(
path,
permission_declarations[0] + 1,
"workflow permissions must use a scope mapping or explicit empty mapping",
for index, match in permission_declarations:
# GitHub applies job-level permissions after the workflow default. Audit
# every declaration so a job cannot reintroduce read-all, write-all, or
# a dynamic grant beneath an otherwise least-privilege workflow.
inline_value = match.group(2)
if inline_value not in {"", "{}"}:
scope = "workflow" if not match.group(1) else "job"
findings.append(
Finding(
path,
index + 1,
f"{scope} permissions must use a scope mapping or explicit empty mapping",
)
)
)
for index, line in enumerate(lines):
line_number = index + 1

View file

@ -113,6 +113,41 @@ steps:
dynamic = self.audit("permissions: ${{ inputs.permissions }}\njobs: {}\n")
self.assertTrue(any("scope mapping" in finding for finding in dynamic))
def test_rejects_broad_or_dynamic_job_permission_overrides(self) -> None:
broad = self.audit(
"""permissions:
contents: read
jobs:
unsafe:
permissions: write-all
"""
)
self.assertTrue(any("job permissions" in finding for finding in broad))
dynamic = self.audit(
"""permissions: {}
jobs:
unsafe:
permissions: ${{ inputs.permissions }}
"""
)
self.assertTrue(any("job permissions" in finding for finding in dynamic))
def test_accepts_explicit_job_permission_mappings(self) -> None:
findings = self.audit(
"""permissions:
contents: read
jobs:
read_only:
permissions: {}
publisher:
permissions:
contents: write
id-token: write
"""
)
self.assertEqual(findings, [])
def test_rejects_shell_template_data_but_accepts_env_data(self) -> None:
findings = self.audit(
"""permissions: {}