Close workflow trust policy gaps

This commit is contained in:
pulse-triage[bot] 2026-08-30 15:06:00 +01:00
parent 21007a8662
commit a0dfdadc5e
7 changed files with 36 additions and 23 deletions

View file

@ -20,7 +20,7 @@ EXPRESSION_RE = re.compile(r"\$\{\{(.*?)\}\}")
# github.token because Actions makes that credential available independently of
# an explicit secrets.GITHUB_TOKEN reference.
SHELL_DATA_CONTEXT_RE = re.compile(
r"(?<![\w.])(?:inputs|secrets)(?:\.|\s*\[)|(?<![\w.])github\.token\b"
r"(?<![\w.])(?:inputs|secrets)\b|(?<![\w.])github\.token\b"
)
# GitHub documents these event fields as attacker-controlled strings. They may
# be passed through env, but interpolating them into a generated shell program
@ -38,9 +38,7 @@ SECRET_CONTEXT_RE = re.compile(
r"\[\s*(['\"])([A-Za-z_][A-Za-z0-9_]*)\2\s*\]"
r")"
)
DYNAMIC_SECRET_CONTEXT_RE = re.compile(
r"(?<![\w.])secrets\s*\[(?!\s*['\"][A-Za-z_][A-Za-z0-9_]*['\"]\s*\])"
)
SECRET_CONTEXT_TOKEN_RE = re.compile(r"(?<![\w.])secrets\b")
# 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"})
@ -127,13 +125,24 @@ def audit_workflow(path: Path) -> list[Finding]:
if _has_trigger(lines, "pull_request"):
for index, line in enumerate(lines):
code = line.split("#", 1)[0]
secret_names = {
match.group(1) or match.group(3)
for match in SECRET_CONTEXT_RE.finditer(code)
}
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 DYNAMIC_SECRET_CONTEXT_RE.search(code)
or has_unresolved_secret_reference
):
findings.append(
Finding(

View file

@ -122,6 +122,7 @@ jobs:
steps:
- run: echo "${{ inputs.name }}"
- run: echo "${{ inputs['name'] }}"
- run: echo "${{ toJSON(secrets) }}"
- run: |
echo "${{ secrets.ACCESS_TOKEN }}"
echo "${{ github.token }}"
@ -133,7 +134,7 @@ jobs:
)
self.assertEqual(
sum("must enter run scripts through env" in finding for finding in findings),
4,
5,
)
def test_rejects_untrusted_github_metadata_in_generated_shell(self) -> None:
@ -173,6 +174,7 @@ jobs:
GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}
GH_TOKEN_BRACKET: ${{ secrets['WORKFLOW_PAT'] }}
DYNAMIC_SECRET: ${{ secrets[vars.SECRET_NAME] }}
WHOLE_SECRET_CONTEXT: ${{ toJSON(secrets) }}
PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
PUBLIC_KEY_BRACKET: ${{ secrets['PULSE_LICENSE_PUBLIC_KEY'] }}
run: echo checked
@ -180,7 +182,7 @@ jobs:
)
self.assertEqual(
sum("must not reference confidential repository secrets" in finding for finding in findings),
3,
4,
)
trusted_push = self.audit(