mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-13 00:34:43 +00:00
Enforce least-privilege job tokens
This commit is contained in:
parent
1d9e66b24c
commit
47c1cee895
3 changed files with 63 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue