diff --git a/scripts/qualification_policy.py b/scripts/qualification_policy.py index 1f41f03..a6c5853 100644 --- a/scripts/qualification_policy.py +++ b/scripts/qualification_policy.py @@ -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.""" @@ -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", @@ -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: diff --git a/tests/test_qualification_policy.py b/tests/test_qualification_policy.py index c7fd3b7..01fe6a7 100644 --- a/tests/test_qualification_policy.py +++ b/tests/test_qualification_policy.py @@ -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(