mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Bound GitHub Actions job runtimes
This commit is contained in:
parent
7650fdaba3
commit
fda955627f
25 changed files with 185 additions and 1 deletions
|
|
@ -45,6 +45,10 @@ 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*$")
|
||||
JOBS_RE = re.compile(r"^(\s*)jobs\s*:\s*$")
|
||||
JOB_RE = re.compile(r"^(\s*)([A-Za-z0-9_-]+)\s*:\s*$")
|
||||
RUNS_ON_RE = re.compile(r"^(\s*)runs-on\s*:")
|
||||
TIMEOUT_RE = re.compile(r"^(\s*)timeout-minutes\s*:\s*(.*?)\s*$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -119,9 +123,80 @@ def _has_trigger(lines: list[str], event: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _audit_runner_job_timeouts(path: Path, lines: list[str]) -> list[Finding]:
|
||||
"""Require each locally executed job to declare one bounded time budget."""
|
||||
findings: list[Finding] = []
|
||||
jobs_index = next(
|
||||
(
|
||||
index
|
||||
for index, line in enumerate(lines)
|
||||
if JOBS_RE.match(line.split("#", 1)[0])
|
||||
),
|
||||
None,
|
||||
)
|
||||
if jobs_index is None:
|
||||
return findings
|
||||
|
||||
jobs_indent = _indent(lines[jobs_index])
|
||||
job_starts: list[tuple[int, int]] = []
|
||||
for index in range(jobs_index + 1, len(lines)):
|
||||
code = lines[index].split("#", 1)[0]
|
||||
if code.strip() and _indent(code) <= jobs_indent:
|
||||
break
|
||||
match = JOB_RE.match(code)
|
||||
if match and len(match.group(1)) == jobs_indent + 2:
|
||||
job_starts.append((index, len(match.group(1))))
|
||||
|
||||
for position, (job_index, job_indent) in enumerate(job_starts):
|
||||
end_index = (
|
||||
job_starts[position + 1][0]
|
||||
if position + 1 < len(job_starts)
|
||||
else len(lines)
|
||||
)
|
||||
direct_indent = job_indent + 2
|
||||
runner_lines: list[int] = []
|
||||
timeout_declarations: list[tuple[int, str]] = []
|
||||
for index in range(job_index + 1, end_index):
|
||||
code = lines[index].split("#", 1)[0]
|
||||
run_match = RUNS_ON_RE.match(code)
|
||||
if run_match and len(run_match.group(1)) == direct_indent:
|
||||
runner_lines.append(index)
|
||||
timeout_match = TIMEOUT_RE.match(code)
|
||||
if timeout_match and len(timeout_match.group(1)) == direct_indent:
|
||||
timeout_declarations.append(
|
||||
(index, timeout_match.group(2).strip())
|
||||
)
|
||||
|
||||
# Reusable-workflow caller jobs have `uses` instead of `runs-on` and
|
||||
# cannot declare timeout-minutes. The called workflow owns its budgets.
|
||||
if not runner_lines:
|
||||
continue
|
||||
if len(timeout_declarations) != 1:
|
||||
findings.append(
|
||||
Finding(
|
||||
path,
|
||||
runner_lines[0] + 1,
|
||||
"runner job must declare explicit timeout-minutes exactly once",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
timeout_index, timeout_value = timeout_declarations[0]
|
||||
if not timeout_value.isdigit() or not 1 <= int(timeout_value) <= 360:
|
||||
findings.append(
|
||||
Finding(
|
||||
path,
|
||||
timeout_index + 1,
|
||||
"runner job timeout-minutes must be a literal integer from 1 through 360",
|
||||
)
|
||||
)
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def audit_workflow(path: Path) -> list[Finding]:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
findings: list[Finding] = []
|
||||
findings = _audit_runner_job_timeouts(path, lines)
|
||||
|
||||
if _has_trigger(lines, "pull_request"):
|
||||
for index, line in enumerate(lines):
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ class WorkflowTrustTest(unittest.TestCase):
|
|||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@{PIN}
|
||||
with:
|
||||
|
|
@ -63,6 +64,74 @@ jobs:
|
|||
self.assertTrue(any("full commit SHA" in finding for finding in findings))
|
||||
self.assertTrue(any("sha256 digest" in finding for finding in findings))
|
||||
|
||||
def test_requires_bounded_literal_runner_job_timeout(self) -> None:
|
||||
missing = self.audit(
|
||||
"""permissions: {}
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- run: echo test
|
||||
"""
|
||||
)
|
||||
self.assertTrue(
|
||||
any("explicit timeout-minutes" in finding for finding in missing)
|
||||
)
|
||||
|
||||
duplicate = self.audit(
|
||||
"""permissions: {}
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- run: echo test
|
||||
"""
|
||||
)
|
||||
self.assertTrue(
|
||||
any("explicit timeout-minutes" in finding for finding in duplicate)
|
||||
)
|
||||
|
||||
dynamic = self.audit(
|
||||
"""permissions: {}
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: ${{ inputs.timeout }}
|
||||
steps:
|
||||
- run: echo test
|
||||
"""
|
||||
)
|
||||
self.assertTrue(
|
||||
any("integer from 1 through 360" in finding for finding in dynamic)
|
||||
)
|
||||
|
||||
for invalid_value in ("0", "361"):
|
||||
invalid = self.audit(
|
||||
f"""permissions: {{}}
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: {invalid_value}
|
||||
steps:
|
||||
- run: echo test
|
||||
"""
|
||||
)
|
||||
self.assertTrue(
|
||||
any("integer from 1 through 360" in finding for finding in invalid)
|
||||
)
|
||||
|
||||
def test_reusable_workflow_jobs_do_not_require_caller_timeout(self) -> None:
|
||||
findings = self.audit(
|
||||
"""permissions: {}
|
||||
jobs:
|
||||
delegated:
|
||||
uses: ./.github/workflows/reusable.yml
|
||||
"""
|
||||
)
|
||||
self.assertEqual(findings, [])
|
||||
|
||||
def test_rejects_implicit_or_unjustified_checkout_credentials(self) -> None:
|
||||
omitted = self.audit(
|
||||
f"""permissions: {{}}
|
||||
|
|
@ -226,6 +295,7 @@ permissions: {}
|
|||
jobs:
|
||||
trusted:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue