Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions scripts/qualification_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -1268,6 +1268,13 @@ def _split_condition(expression: str, operator: str) -> list[str]:
return parts


def _supports_condition_negation(operand: str) -> bool:
# ! binds before comparison. Only invert a grouped expression or literal.
return operand.lower() in {"true", "false"} or (
operand.startswith("(") and _strip_balanced_parentheses(operand) != operand
)


def _condition_truth_for_unprotected_dispatch(expression: str, protected_ref: str) -> tuple[bool, bool]:
"""Return whether an expression can be true/false for a dispatch from another ref."""

Expand All @@ -1281,8 +1288,14 @@ def _condition_truth_for_unprotected_dispatch(expression: str, protected_ref: st
values = [_condition_truth_for_unprotected_dispatch(part, protected_ref) for part in conjunction]
return all(value[0] for value in values), any(value[1] for value in values)
if expression.startswith("!") and not expression.startswith("!="):
can_be_true, can_be_false = _condition_truth_for_unprotected_dispatch(expression[1:], protected_ref)
operand = expression[1:].strip()
if not _supports_condition_negation(operand):
return True, True
can_be_true, can_be_false = _condition_truth_for_unprotected_dispatch(operand, protected_ref)
return can_be_false, can_be_true
if expression.lower() in {"true", "false"}:
value = expression.lower() == "true"
return value, not value

comparison = re.fullmatch(
r"github\.(event_name|ref)\s*(==|!=)\s*(['\"])(.*?)\3",
Expand All @@ -1293,7 +1306,10 @@ def _condition_truth_for_unprotected_dispatch(expression: str, protected_ref: st
return True, True
field, operator, _quote, expected = comparison.groups()
if field == "event_name":
equality = expected == "workflow_dispatch"
# GitHub event names are ASCII; other literals cannot establish trust.
if not expected.isascii():
return True, True
equality = expected.lower() == "workflow_dispatch"
value = equality if operator == "==" else not equality
return value, not value
if expected != protected_ref:
Expand Down
42 changes: 42 additions & 0 deletions tests/test_qualification_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,48 @@ def approved_action(self, repository: str) -> tuple[str, str]:
commit, version = next(iter(releases.items()))
return commit, version

def privileged_dispatch(self, condition: str) -> str:
return workflow("- run: echo checked", trigger="workflow_dispatch").replace(
" runs-on:",
f" if: ${{{{ {condition} }}}}\n permissions:\n contents: write\n runs-on:",
)

def test_dispatch_rejects_case_and_bare_negation_bypasses(self) -> None:
for condition in (
"github.event_name == 'WORKFLOW_DISPATCH'",
"github.event_name == 'WorkFlow_Dispatch'",
"!github.event_name != 'push'",
"(!github.event_name != 'push') && github.ref != 'refs/heads/main'",
"!github.ref != 'refs/heads/main'",
"github.ref == 'refs/heads/main' || github.event_name == 'WORKFLOW_DISPATCH'",
"!(github.ref == 'refs/heads/main')",
"github.ref == 'refs/heads/other'",
"success() || github.ref == 'refs/heads/main'",
"!false",
"github.event_name != 'workflow_dispatch'",
"github.event_name != 'workflow_diſpatch'",
"falſe",
):
candidate = self.privileged_dispatch(condition)
with self.subTest(condition=condition), self.assertRaisesRegex(
PolicyError, "privileged workflow_dispatch.*outside"
):
self.scan(candidate)

def test_dispatch_retains_protected_ref_guards_and_boolean_composition(self) -> None:
for condition in (
"github.ref == 'refs/heads/main'",
"!(github.ref != 'refs/heads/main')",
"github.event_name == 'push' || github.ref == 'refs/heads/main'",
"github.event_name != 'WORKFLOW_DISPATCH' || github.ref == 'refs/heads/main'",
"github.event_name == 'WORKFLOW_DISPATCH' && github.ref == 'refs/heads/main'",
"always() && github.ref == 'refs/heads/main'",
"!true",
"false",
):
with self.subTest(condition=condition):
self.scan(self.privileged_dispatch(condition))

def test_checked_in_policy_is_valid_and_has_no_retired_consumers(self) -> None:
validate_policy(self.policy)
self.assertEqual(
Expand Down
Loading