diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 6df9ad0b6e..f7e7523573 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -12,6 +12,13 @@ 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. +Every job that executes on a runner declares one literal `timeout-minutes` +budget. Reusable-workflow caller jobs cannot set a timeout and delegate that +responsibility to each runner job in the called workflow. The trust check +rejects omitted, dynamic, duplicate, zero, or platform-invalid budgets so a +stalled check or publisher cannot silently occupy a runner for GitHub's +six-hour default. + 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, diff --git a/.github/workflows/backfill-release-assets.yml b/.github/workflows/backfill-release-assets.yml index 72b2ccf04f..9bc471409b 100644 --- a/.github/workflows/backfill-release-assets.yml +++ b/.github/workflows/backfill-release-assets.yml @@ -18,6 +18,7 @@ concurrency: jobs: backfill: runs-on: ubuntu-24.04 + timeout-minutes: 30 permissions: contents: write diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index e12db2f5e1..a153419510 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -27,6 +27,7 @@ jobs: secret-scan: name: Secret Scan runs-on: ubuntu-24.04 + timeout-minutes: 10 env: GITLEAKS_VERSION: "8.30.1" GITLEAKS_LINUX_X64_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" @@ -67,6 +68,7 @@ jobs: changes: name: Detect changed areas runs-on: ubuntu-24.04 + timeout-minutes: 5 outputs: code: ${{ steps.filter.outputs.code }} steps: @@ -106,6 +108,7 @@ jobs: needs: changes if: needs.changes.outputs.code == 'true' runs-on: ubuntu-24.04 + timeout-minutes: 25 env: FRONTEND_DIR: frontend-modern @@ -173,6 +176,7 @@ jobs: needs: changes if: needs.changes.outputs.code == 'true' runs-on: ubuntu-24.04 + timeout-minutes: 45 strategy: fail-fast: false matrix: @@ -230,6 +234,7 @@ jobs: needs: changes if: needs.changes.outputs.code == 'true' runs-on: ubuntu-24.04 + timeout-minutes: 30 steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -262,6 +267,7 @@ jobs: needs: changes if: needs.changes.outputs.code == 'true' runs-on: ubuntu-24.04 + timeout-minutes: 30 steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 diff --git a/.github/workflows/canonical-governance.yml b/.github/workflows/canonical-governance.yml index 87ca43f5f2..775b0b1a1e 100644 --- a/.github/workflows/canonical-governance.yml +++ b/.github/workflows/canonical-governance.yml @@ -15,6 +15,7 @@ permissions: jobs: governance: runs-on: ubuntu-24.04 + timeout-minutes: 30 # Keep pull-request governance free of repository credentials. Cross-repo # evidence checks run after merge in canonical-private-governance.yml, so # its credential-bearing job never executes from an unmerged PR revision. diff --git a/.github/workflows/canonical-private-governance.yml b/.github/workflows/canonical-private-governance.yml index 881697f701..219b19e05c 100644 --- a/.github/workflows/canonical-private-governance.yml +++ b/.github/workflows/canonical-private-governance.yml @@ -19,6 +19,7 @@ concurrency: jobs: private-governance: runs-on: ubuntu-24.04 + timeout-minutes: 30 defaults: run: working-directory: repos/pulse diff --git a/.github/workflows/close-needs-retest-timeout.yml b/.github/workflows/close-needs-retest-timeout.yml index c6071c99e5..e3ac58b075 100644 --- a/.github/workflows/close-needs-retest-timeout.yml +++ b/.github/workflows/close-needs-retest-timeout.yml @@ -20,6 +20,7 @@ permissions: jobs: close-timeouts: runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - name: Mint triage bot token id: triage-token diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 34e1c04d92..f2e4d3190d 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -2008,6 +2008,7 @@ jobs: - activate_release if: ${{ always() && needs.prepare.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }} runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - name: Checkout release integrity control uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 diff --git a/.github/workflows/helm-ci.yml b/.github/workflows/helm-ci.yml index 89fbc4151d..a5313e01ac 100644 --- a/.github/workflows/helm-ci.yml +++ b/.github/workflows/helm-ci.yml @@ -23,6 +23,7 @@ jobs: lint: name: Lint and Render Chart runs-on: ubuntu-24.04 + timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 diff --git a/.github/workflows/helm-pages.yml b/.github/workflows/helm-pages.yml index bf20358d0b..f1e9b164fa 100644 --- a/.github/workflows/helm-pages.yml +++ b/.github/workflows/helm-pages.yml @@ -33,6 +33,7 @@ permissions: jobs: release: runs-on: ubuntu-24.04 + timeout-minutes: 20 steps: - name: Checkout release verification control uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 diff --git a/.github/workflows/issue-version-label-sync.yml b/.github/workflows/issue-version-label-sync.yml index b08d8498f9..576185f122 100644 --- a/.github/workflows/issue-version-label-sync.yml +++ b/.github/workflows/issue-version-label-sync.yml @@ -14,6 +14,7 @@ jobs: sync: if: ${{ github.event.issue.pull_request == null }} runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - name: Mint triage bot token id: triage-token diff --git a/.github/workflows/issue-version-retest-comment.yml b/.github/workflows/issue-version-retest-comment.yml index 5ebedb364d..fe7ed5f58e 100644 --- a/.github/workflows/issue-version-retest-comment.yml +++ b/.github/workflows/issue-version-retest-comment.yml @@ -15,6 +15,7 @@ concurrency: jobs: comment: runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - name: Mint triage bot token id: triage-token diff --git a/.github/workflows/promote-floating-tags.yml b/.github/workflows/promote-floating-tags.yml index 7fc6c85517..ff984ca60c 100644 --- a/.github/workflows/promote-floating-tags.yml +++ b/.github/workflows/promote-floating-tags.yml @@ -39,6 +39,7 @@ permissions: jobs: promote-images: runs-on: ubuntu-24.04 + timeout-minutes: 30 permissions: contents: read packages: write diff --git a/.github/workflows/public-docs.yml b/.github/workflows/public-docs.yml index 7696b197b6..06efb084b9 100644 --- a/.github/workflows/public-docs.yml +++ b/.github/workflows/public-docs.yml @@ -28,6 +28,7 @@ concurrency: jobs: check: runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - name: Check out repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 diff --git a/.github/workflows/publish-helm-chart.yml b/.github/workflows/publish-helm-chart.yml index 79334b4874..bb1669d702 100644 --- a/.github/workflows/publish-helm-chart.yml +++ b/.github/workflows/publish-helm-chart.yml @@ -46,6 +46,7 @@ jobs: publish: name: Package and Push Helm Chart runs-on: ubuntu-24.04 + timeout-minutes: 30 outputs: chart_digest: ${{ steps.proof.outputs.chart_digest }} permissions: diff --git a/.github/workflows/recover-demo-server.yml b/.github/workflows/recover-demo-server.yml index 19a2c02d6b..2f41900739 100644 --- a/.github/workflows/recover-demo-server.yml +++ b/.github/workflows/recover-demo-server.yml @@ -13,6 +13,7 @@ concurrency: jobs: recover: runs-on: ubuntu-24.04 + timeout-minutes: 45 environment: demo-stable env: DEMO_EXPECTED_HOSTNAME: ${{ vars.DEMO_EXPECTED_HOSTNAME }} diff --git a/.github/workflows/release-convergence.yml b/.github/workflows/release-convergence.yml index 36a321858c..25db897b02 100644 --- a/.github/workflows/release-convergence.yml +++ b/.github/workflows/release-convergence.yml @@ -415,6 +415,7 @@ jobs: - update_stable_demo if: ${{ always() }} runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - name: Require every applicable surface to converge env: @@ -466,6 +467,7 @@ jobs: - convergence_verdict if: ${{ always() && needs.acquire_customer_promotion_lease.outputs.lock_sha != '' }} runs-on: ubuntu-24.04 + timeout-minutes: 10 permissions: contents: write steps: diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index a2f7524da9..33599d712a 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -476,6 +476,7 @@ jobs: - demo_path_preflight if: ${{ always() }} runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - name: Enforce exact-SHA rehearsal outcomes env: diff --git a/.github/workflows/repo-boundary-audit.yml b/.github/workflows/repo-boundary-audit.yml index 3166a87c92..8a7f9d4a69 100644 --- a/.github/workflows/repo-boundary-audit.yml +++ b/.github/workflows/repo-boundary-audit.yml @@ -10,6 +10,7 @@ permissions: jobs: audit: runs-on: ubuntu-24.04 + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 33e5f174fc..806aa6edd9 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -288,6 +288,7 @@ jobs: name: Container trust and support window if: ${{ github.event_name != 'workflow_run' && github.event.schedule != '17 */6 * * *' }} runs-on: ubuntu-24.04 + timeout-minutes: 30 steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -308,6 +309,7 @@ jobs: name: Go Vulnerability Scan (${{ matrix.directory }}) if: ${{ github.event_name != 'workflow_run' && github.event.schedule != '17 */6 * * *' }} runs-on: ubuntu-24.04 + timeout-minutes: 45 strategy: fail-fast: false matrix: @@ -336,6 +338,7 @@ jobs: name: npm Dependency Audit (${{ matrix.name }}) if: ${{ github.event_name != 'workflow_run' && github.event.schedule != '17 */6 * * *' }} runs-on: ubuntu-24.04 + timeout-minutes: 20 strategy: fail-fast: false matrix: diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 0c7db03cdd..0b232b6b44 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -309,6 +309,7 @@ jobs: e2e-verdict: name: E2E verdict runs-on: ubuntu-24.04 + timeout-minutes: 5 needs: - tier-selection - e2e diff --git a/.github/workflows/unified-agent-native.yml b/.github/workflows/unified-agent-native.yml index 10a7c661a4..8b04da394b 100644 --- a/.github/workflows/unified-agent-native.yml +++ b/.github/workflows/unified-agent-native.yml @@ -71,6 +71,7 @@ jobs: runner: windows-2025 unix: false runs-on: ${{ matrix.runner }} + timeout-minutes: 45 steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -177,6 +178,7 @@ jobs: freebsd-build-contract: name: FreeBSD cross-build contract runs-on: ubuntu-24.04 + timeout-minutes: 30 steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 diff --git a/.github/workflows/update-demo-server.yml b/.github/workflows/update-demo-server.yml index 74118cd956..13bfb673eb 100644 --- a/.github/workflows/update-demo-server.yml +++ b/.github/workflows/update-demo-server.yml @@ -44,6 +44,7 @@ permissions: jobs: resolve: runs-on: ubuntu-24.04 + timeout-minutes: 10 outputs: tag: ${{ steps.target.outputs.tag }} target: ${{ steps.target.outputs.target }} @@ -173,6 +174,7 @@ jobs: needs: resolve if: needs.resolve.outputs.skip != 'true' runs-on: ubuntu-24.04 + timeout-minutes: 60 environment: ${{ needs.resolve.outputs.environment_name }} env: DEMO_EXPECTED_HOSTNAME: ${{ vars.DEMO_EXPECTED_HOSTNAME }} diff --git a/.github/workflows/validate-release-assets.yml b/.github/workflows/validate-release-assets.yml index c3bf72a872..a095274abe 100644 --- a/.github/workflows/validate-release-assets.yml +++ b/.github/workflows/validate-release-assets.yml @@ -64,6 +64,7 @@ permissions: jobs: validate: runs-on: ubuntu-24.04 + timeout-minutes: 45 permissions: contents: write issues: write diff --git a/scripts/check_workflow_trust.py b/scripts/check_workflow_trust.py index fc7dee9635..a59e28f7d2 100644 --- a/scripts/check_workflow_trust.py +++ b/scripts/check_workflow_trust.py @@ -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): diff --git a/scripts/tests/test_workflow_trust.py b/scripts/tests/test_workflow_trust.py index fc657d15bd..2b73fdeae2 100644 --- a/scripts/tests/test_workflow_trust.py +++ b/scripts/tests/test_workflow_trust.py @@ -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 }}