mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Keep unsigned caches out of privileged workflows
Change-source: pulse-maintainer
This commit is contained in:
@@ -42,6 +42,14 @@ be interpolated into the generated shell program. Outputs remain data even
|
||||
when an intermediate step parsed or validated them, because later substitution
|
||||
would turn their value back into shell source.
|
||||
|
||||
Jobs that receive confidential repository secrets or a write-capable
|
||||
`GITHUB_TOKEN` do not restore or save caches. This includes setup-action
|
||||
dependency caches, direct Actions caches, and external BuildKit cache imports:
|
||||
cache contents are unsigned mutable build input, while provenance only records
|
||||
what the workflow produced. Read-only jobs may still cache locked dependencies;
|
||||
the intentionally public legacy license key is not treated as a confidential
|
||||
credential.
|
||||
|
||||
Passing data through `env` does not make it safe to append to the runner's
|
||||
`GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH`, or `GITHUB_STATE` command files.
|
||||
The audit follows workflow data and values read from the event payload through
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
cache: false
|
||||
|
||||
- name: Install Syft
|
||||
run: |
|
||||
|
||||
@@ -285,7 +285,7 @@ jobs:
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
cache: false
|
||||
|
||||
- name: Build, sign, and notarize agent binaries
|
||||
shell: bash
|
||||
@@ -389,7 +389,7 @@ jobs:
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
cache: false
|
||||
|
||||
- name: Build unsigned agent binaries
|
||||
shell: pwsh
|
||||
@@ -705,14 +705,13 @@ jobs:
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
cache: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'frontend-modern/package-lock.json'
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install release prerequisites
|
||||
run: |
|
||||
|
||||
@@ -62,8 +62,7 @@ jobs:
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: repos/pulse/go.mod
|
||||
cache: true
|
||||
cache-dependency-path: repos/pulse/go.sum
|
||||
cache: false
|
||||
|
||||
- name: Stub embedded frontend assets for Go tests
|
||||
run: bash scripts/ensure_test_assets.sh
|
||||
|
||||
@@ -136,7 +136,7 @@ jobs:
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
cache: false
|
||||
|
||||
- name: Build hosted qualification subjects
|
||||
env:
|
||||
|
||||
@@ -1323,7 +1323,7 @@ jobs:
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
cache: false
|
||||
|
||||
- name: Install Syft
|
||||
run: |
|
||||
|
||||
@@ -43,6 +43,7 @@ jobs:
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
|
||||
- name: Verify eval credentials are configured
|
||||
env:
|
||||
|
||||
@@ -59,6 +59,7 @@ jobs:
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
|
||||
- name: Validate qualification catalogue
|
||||
run: go run ./cmd/patrol-qualify -mode validate
|
||||
|
||||
@@ -149,7 +149,6 @@ jobs:
|
||||
push: true
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:buildcache
|
||||
tags: |
|
||||
rcourtman/pulse:${{ steps.version.outputs.tag }}
|
||||
rcourtman/pulse:${{ steps.version.outputs.version }}
|
||||
@@ -188,7 +187,6 @@ jobs:
|
||||
push: true
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-control-plane:buildcache
|
||||
tags: |
|
||||
rcourtman/pulse-control-plane:${{ steps.version.outputs.tag }}
|
||||
rcourtman/pulse-control-plane:${{ steps.version.outputs.version }}
|
||||
|
||||
@@ -246,8 +246,7 @@ jobs:
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'frontend-modern/package-lock.json'
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm --prefix frontend-modern ci
|
||||
@@ -277,7 +276,7 @@ jobs:
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
cache: false
|
||||
|
||||
- name: Run backend tests
|
||||
# Serial package execution keeps non-race SLO tests meaningful on
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
cache: false
|
||||
|
||||
- name: Verify isolated test-signing configuration
|
||||
shell: pwsh
|
||||
|
||||
@@ -243,8 +243,7 @@ jobs:
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: tests/integration/package-lock.json
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install Playwright dependencies
|
||||
working-directory: tests/integration
|
||||
|
||||
@@ -304,6 +304,7 @@ jobs:
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
|
||||
- name: Wait for release assets
|
||||
if: inputs.verify_only != true
|
||||
|
||||
+181
-47
@@ -97,6 +97,26 @@ SECRET_CONTEXT_RE = re.compile(
|
||||
r")"
|
||||
)
|
||||
SECRET_CONTEXT_TOKEN_RE = re.compile(r"(?<![\w.])secrets\b")
|
||||
CACHE_ACTION_PREFIXES = ("actions/cache@", "actions/cache/")
|
||||
SETUP_CACHE_ACTION_PREFIXES = (
|
||||
"actions/setup-go@",
|
||||
"actions/setup-node@",
|
||||
"actions/setup-python@",
|
||||
"gradle/actions/setup-gradle@",
|
||||
"ruby/setup-ruby@",
|
||||
)
|
||||
AUTO_CACHE_DISABLE_INPUTS = {
|
||||
"actions/setup-go@": "cache",
|
||||
"actions/setup-node@": "package-manager-cache",
|
||||
}
|
||||
CACHE_INPUT_RE = re.compile(
|
||||
rf"^\s*{_yaml_key('cache')}\s*:\s*(.*?)\s*$", re.IGNORECASE
|
||||
)
|
||||
EXTERNAL_CACHE_INPUT_RE = re.compile(
|
||||
rf"^\s*(?:{_yaml_key('cache-from')}|{_yaml_key('cache-to')})\s*:",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
WRITE_PERMISSION_RE = re.compile(r"^\s+[A-Za-z-]+\s*:\s*write\s*$")
|
||||
# This value is intentionally public and only uses secret storage as a legacy
|
||||
# configuration mechanism. Confidential credentials have no PR exception.
|
||||
NON_CONFIDENTIAL_PULL_REQUEST_SECRETS = frozenset({"PULSE_LICENSE_PUBLIC_KEY"})
|
||||
@@ -151,6 +171,41 @@ def _checkout_block(lines: list[str], uses_index: int) -> list[tuple[int, str]]:
|
||||
return block
|
||||
|
||||
|
||||
def _runner_job_ranges(lines: list[str]) -> list[tuple[int, int, int]]:
|
||||
"""Return (start, end, indent) for each locally executed workflow job."""
|
||||
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 []
|
||||
|
||||
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))))
|
||||
|
||||
return [
|
||||
(
|
||||
job_index,
|
||||
job_starts[position + 1][0]
|
||||
if position + 1 < len(job_starts)
|
||||
else len(lines),
|
||||
job_indent,
|
||||
)
|
||||
for position, (job_index, job_indent) in enumerate(job_starts)
|
||||
]
|
||||
|
||||
|
||||
def _run_script_lines(lines: list[str], run_index: int) -> list[tuple[int, str]]:
|
||||
"""Return the source lines GitHub will materialize as a run script."""
|
||||
match = RUN_RE.match(lines[run_index])
|
||||
@@ -328,6 +383,23 @@ def _is_untrusted_expression(value: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _has_confidential_secret_reference(lines: list[str]) -> bool:
|
||||
"""Return whether workflow lines can resolve a confidential secret."""
|
||||
for line in lines:
|
||||
for expression in EXPRESSION_RE.findall(line.split("#", 1)[0]):
|
||||
static_references = list(SECRET_CONTEXT_RE.finditer(expression))
|
||||
secret_names = {
|
||||
match.group(1) or match.group(3) for match in static_references
|
||||
}
|
||||
if secret_names - NON_CONFIDENTIAL_PULL_REQUEST_SECRETS:
|
||||
return True
|
||||
if len(SECRET_CONTEXT_TOKEN_RE.findall(expression)) != len(
|
||||
static_references
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _audit_command_file_data(
|
||||
path: Path,
|
||||
lines: list[str],
|
||||
@@ -512,33 +584,7 @@ def _audit_workflow_run_trigger(path: Path, lines: list[str]) -> list[Finding]:
|
||||
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)
|
||||
)
|
||||
for job_index, end_index, job_indent in _runner_job_ranges(lines):
|
||||
direct_indent = job_indent + 2
|
||||
runner_lines: list[int] = []
|
||||
timeout_declarations: list[tuple[int, str]] = []
|
||||
@@ -580,9 +626,116 @@ def _audit_runner_job_timeouts(path: Path, lines: list[str]) -> list[Finding]:
|
||||
return findings
|
||||
|
||||
|
||||
def _audit_privileged_job_caches(path: Path, lines: list[str]) -> list[Finding]:
|
||||
"""Keep unsigned cache state out of credential- and write-capable jobs."""
|
||||
findings: list[Finding] = []
|
||||
top_level_write = any(
|
||||
WRITE_PERMISSION_RE.match(line.split("#", 1)[0]) and _indent(line) == 2
|
||||
for line in lines
|
||||
)
|
||||
|
||||
for job_index, end_index, _ in _runner_job_ranges(lines):
|
||||
job_lines = lines[job_index:end_index]
|
||||
privileged = top_level_write or _has_confidential_secret_reference(
|
||||
job_lines
|
||||
) or any(
|
||||
WRITE_PERMISSION_RE.match(line.split("#", 1)[0])
|
||||
for line in job_lines
|
||||
)
|
||||
if not privileged:
|
||||
continue
|
||||
|
||||
for relative_index, line in enumerate(job_lines):
|
||||
index = job_index + relative_index
|
||||
code = line.split("#", 1)[0]
|
||||
dependency_match = USES_RE.search(code)
|
||||
if dependency_match:
|
||||
dependency = dependency_match.group(1).strip("'\"").lower()
|
||||
if dependency.startswith(CACHE_ACTION_PREFIXES):
|
||||
findings.append(
|
||||
Finding(
|
||||
path,
|
||||
index + 1,
|
||||
"credential- or write-capable jobs must not restore "
|
||||
"or save unsigned caches",
|
||||
)
|
||||
)
|
||||
elif dependency.startswith(SETUP_CACHE_ACTION_PREFIXES):
|
||||
action_block = _checkout_block(lines, index)
|
||||
unsafe_cache_lines: list[int] = []
|
||||
for cache_index, cache_line in action_block:
|
||||
cache_match = CACHE_INPUT_RE.match(cache_line.split("#", 1)[0])
|
||||
if not cache_match:
|
||||
continue
|
||||
value = cache_match.group(1).strip().strip("'\"").lower()
|
||||
if value != "false":
|
||||
unsafe_cache_lines.append(cache_index)
|
||||
|
||||
required_disable_input = next(
|
||||
(
|
||||
input_name
|
||||
for prefix, input_name in AUTO_CACHE_DISABLE_INPUTS.items()
|
||||
if dependency.startswith(prefix)
|
||||
),
|
||||
None,
|
||||
)
|
||||
disable_declarations: list[tuple[int, str]] = []
|
||||
if required_disable_input:
|
||||
disable_re = re.compile(
|
||||
rf"^\s*{_yaml_key(required_disable_input)}\s*:\s*(.*?)\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
for block_index, block_line in action_block:
|
||||
disable_match = disable_re.match(
|
||||
block_line.split("#", 1)[0]
|
||||
)
|
||||
if disable_match:
|
||||
disable_declarations.append(
|
||||
(block_index, disable_match.group(1))
|
||||
)
|
||||
explicitly_disabled = (
|
||||
required_disable_input is None
|
||||
or (
|
||||
len(disable_declarations) == 1
|
||||
and disable_declarations[0][1]
|
||||
.strip()
|
||||
.strip("'\"")
|
||||
.lower()
|
||||
== "false"
|
||||
)
|
||||
)
|
||||
if unsafe_cache_lines or not explicitly_disabled:
|
||||
finding_index = (
|
||||
unsafe_cache_lines[0]
|
||||
if unsafe_cache_lines
|
||||
else index
|
||||
)
|
||||
findings.append(
|
||||
Finding(
|
||||
path,
|
||||
finding_index + 1,
|
||||
"credential- or write-capable jobs must explicitly "
|
||||
"disable setup-action caches",
|
||||
)
|
||||
)
|
||||
|
||||
if EXTERNAL_CACHE_INPUT_RE.match(code):
|
||||
findings.append(
|
||||
Finding(
|
||||
path,
|
||||
index + 1,
|
||||
"credential- or write-capable jobs must not import or "
|
||||
"export external build caches",
|
||||
)
|
||||
)
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def audit_workflow(path: Path) -> list[Finding]:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
findings = _audit_runner_job_timeouts(path, lines)
|
||||
findings.extend(_audit_privileged_job_caches(path, lines))
|
||||
findings.extend(_audit_workflow_run_trigger(path, lines))
|
||||
has_workflow_run_trigger = _has_trigger(lines, "workflow_run")
|
||||
|
||||
@@ -598,26 +751,7 @@ def audit_workflow(path: Path) -> list[Finding]:
|
||||
|
||||
if _has_trigger(lines, "pull_request"):
|
||||
for index, line in enumerate(lines):
|
||||
code = line.split("#", 1)[0]
|
||||
expressions = EXPRESSION_RE.findall(code)
|
||||
secret_names: set[str] = set()
|
||||
has_unresolved_secret_reference = False
|
||||
for expression in expressions:
|
||||
static_references = list(SECRET_CONTEXT_RE.finditer(expression))
|
||||
secret_names.update(
|
||||
match.group(1) or match.group(3) for match in static_references
|
||||
)
|
||||
if len(SECRET_CONTEXT_TOKEN_RE.findall(expression)) != len(
|
||||
static_references
|
||||
):
|
||||
# Whole-context and dynamic references can expose any
|
||||
# repository secret, so they cannot use the public-key
|
||||
# exception reserved for a statically named value.
|
||||
has_unresolved_secret_reference = True
|
||||
if (
|
||||
secret_names - NON_CONFIDENTIAL_PULL_REQUEST_SECRETS
|
||||
or has_unresolved_secret_reference
|
||||
):
|
||||
if _has_confidential_secret_reference([line]):
|
||||
findings.append(
|
||||
Finding(
|
||||
path,
|
||||
|
||||
@@ -366,6 +366,92 @@ jobs:
|
||||
)
|
||||
self.assertEqual(findings, [])
|
||||
|
||||
def test_privileged_jobs_cannot_consume_unsigned_caches(self) -> None:
|
||||
findings = self.audit(
|
||||
f"""permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
secret_job:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/setup-go@{PIN}
|
||||
with:
|
||||
cache: true
|
||||
- uses: actions/cache/restore@{PIN}
|
||||
with:
|
||||
path: ~/.cache
|
||||
key: privileged
|
||||
- env:
|
||||
SIGNING_TOKEN: ${{{{ secrets.SIGNING_TOKEN }}}}
|
||||
run: echo signed
|
||||
publisher:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: docker/build-push-action@{PIN}
|
||||
with:
|
||||
cache-from: type=registry,ref=example.invalid/cache
|
||||
cache-to: type=registry,ref=example.invalid/cache
|
||||
"""
|
||||
)
|
||||
self.assertEqual(
|
||||
sum("credential- or write-capable jobs" in finding for finding in findings),
|
||||
4,
|
||||
)
|
||||
|
||||
def test_read_only_jobs_can_cache_and_privileged_jobs_can_disable_cache(self) -> None:
|
||||
findings = self.audit(
|
||||
f"""permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
read_only:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/cache@{PIN}
|
||||
with:
|
||||
path: ~/.cache
|
||||
key: read-only
|
||||
privileged:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/setup-go@{PIN}
|
||||
with:
|
||||
cache: false
|
||||
- uses: actions/setup-node@{PIN}
|
||||
with:
|
||||
package-manager-cache: false
|
||||
- env:
|
||||
SIGNING_TOKEN: ${{{{ secrets.SIGNING_TOKEN }}}}
|
||||
run: echo signed
|
||||
"""
|
||||
)
|
||||
self.assertEqual(findings, [])
|
||||
|
||||
def test_privileged_setup_actions_must_disable_automatic_caching(self) -> None:
|
||||
findings = self.audit(
|
||||
f"""permissions: {{}}
|
||||
jobs:
|
||||
privileged:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/setup-go@{PIN}
|
||||
- uses: actions/setup-node@{PIN}
|
||||
- env:
|
||||
SIGNING_TOKEN: ${{{{ secrets.SIGNING_TOKEN }}}}
|
||||
run: echo signed
|
||||
"""
|
||||
)
|
||||
self.assertEqual(
|
||||
sum("explicitly disable setup-action caches" in finding for finding in findings),
|
||||
2,
|
||||
)
|
||||
|
||||
def test_rejects_shell_template_data_but_accepts_env_data(self) -> None:
|
||||
findings = self.audit(
|
||||
"""permissions: {}
|
||||
|
||||
Reference in New Issue
Block a user