mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +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
11
.github/workflows/README.md
vendored
11
.github/workflows/README.md
vendored
|
|
@ -12,11 +12,12 @@ Use `false` unless a later command in the same job performs an authenticated
|
|||
Git write. The small number of write-path exceptions use `true` with the
|
||||
machine-checked `# required: authenticated git writes` rationale.
|
||||
|
||||
Each workflow declares its default `GITHUB_TOKEN` permissions explicitly and
|
||||
enumerates scopes instead of using `read-all` or `write-all`. Workflow inputs,
|
||||
secrets, `github.token`, and attacker-controlled GitHub event metadata are
|
||||
passed to `run` steps through `env`; they are data and must never be
|
||||
interpolated into the generated shell program.
|
||||
Each workflow declares its default `GITHUB_TOKEN` permissions explicitly, and
|
||||
both workflow defaults and job-level overrides enumerate scopes instead of
|
||||
using `read-all`, `write-all`, or dynamic grants. Workflow inputs, secrets,
|
||||
`github.token`, and attacker-controlled GitHub event metadata are passed to
|
||||
`run` steps through `env`; they are data and must never be interpolated into
|
||||
the generated shell program.
|
||||
|
||||
Workflows triggered by `pull_request` cannot reference confidential repository
|
||||
secrets. Canonical governance therefore keeps its pull-request checks local to
|
||||
|
|
|
|||
|
|
@ -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,15 +172,18 @@ 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: {}",
|
||||
}:
|
||||
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,
|
||||
permission_declarations[0] + 1,
|
||||
"workflow permissions must use a scope mapping or explicit empty mapping",
|
||||
index + 1,
|
||||
f"{scope} permissions must use a scope mapping or explicit empty mapping",
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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