Skip to content

Commit 4e29c71

Browse files
izaitsevfbIvan Zaitsev
andauthored
autorevert: only honor disable killswitch when label applied by a write-access user (#8229)
## Summary `check_autorevert_disabled()` treated **any** open issue carrying the `ci: disable-autorevert` label as a global disable, with no authority check. Because GitHub applies an issue template's labels on the author's behalf at creation time **regardless of the author's permissions**, a user with no write access could disable autorevert for the entire repo just by opening the disable-autorevert issue. This happened in production: pytorch/pytorch#188383 was opened by a `NONE`-permission user from the `[DISABLE AUTOREVERT]` template, which auto-applied the label and kept autorevert disabled for ~15h until a maintainer stripped the label. ## Change Gate the killswitch on **who applied the label**, not the issue author: - Find the most recent `labeled` event for `ci: disable-autorevert` (via `issue.get_events()`, latest wins so a remove-then-re-add reflects whoever re-applied it). - Require that actor to hold `write` / `maintain` / `admin` permission. `triage` is intentionally excluded — triagers may manage labels but shouldn't be able to globally disable autorevert. - Checking the applier rather than the author means a maintainer labeling *someone else's* issue is still correctly honored (the common triage workflow). - **Fail safe:** if the applier or their permission can't be resolved, skip that issue (autorevert stays ON) and keep evaluating the rest — consistent with the existing "on error, allow autorevert to continue" behavior. Adds `test_autorevert_circuit_breaker.py` covering: the exploit case (unprivileged applier → not disabled), maintainer-labels-foreign-issue → disabled, latest-applier-wins after relabel, `triage` rejected, no-labeled-event anomaly, and the fail-safe / keep-evaluating paths (12 tests). ## Companion PR The matching defense at the template layer (stop auto-applying the label so only a write/triage user can ever add it) is in **pytorch/pytorch**: see the `disable-autorevert` issue-template change. This lambda gate is the durable guarantee independent of any one repo's template config. Ref: pytorch/pytorch#188383 Signed-off-by: Ivan Zaitsev <izaitsevfb@meta.com> Co-authored-by: Ivan Zaitsev <izaitsevfb@meta.com>
1 parent 3986d17 commit 4e29c71

2 files changed

Lines changed: 264 additions & 10 deletions

File tree

aws/lambda/pytorch-auto-revert/pytorch_auto_revert/autorevert_circuit_breaker.py

Lines changed: 85 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,53 @@
66

77
logger = logging.getLogger(__name__)
88

9+
# The label whose presence on an open issue disables autorevert.
10+
DISABLE_AUTOREVERT_LABEL = "ci: disable-autorevert"
11+
12+
# Repository permission levels that authorize disabling autorevert. Adding a
13+
# label to an issue requires triage/write access, but GitHub applies an issue
14+
# template's labels on the author's behalf at creation time regardless of the
15+
# author's permissions, so label presence alone is NOT proof of authority. We
16+
# require the user who APPLIED the label to hold write access. "triage" is
17+
# intentionally excluded: triagers may manage labels but should not be able to
18+
# globally disable autorevert.
19+
_AUTHORIZED_PERMISSIONS = frozenset({"admin", "write", "maintain"})
20+
21+
22+
def _label_applier_login(issue, label_name: str):
23+
"""Return the login of the user who most recently applied ``label_name`` to
24+
``issue``, or ``None`` if it cannot be determined.
25+
26+
The label *applier* — not the issue author — is the actor whose authority
27+
gates the killswitch. For a template-auto-applied label, GitHub records the
28+
"labeled" event with the issue author as the actor, so an unprivileged
29+
author who tripped the label via the template is correctly rejected here;
30+
while a maintainer who labels someone else's issue is correctly honored.
31+
"""
32+
applier = None
33+
for ev in issue.get_events():
34+
if (
35+
ev.event == "labeled"
36+
and ev.label is not None
37+
and ev.label.name == label_name
38+
):
39+
# get_events() is chronological; keep the latest applier so a
40+
# remove-then-re-add reflects whoever re-applied the label.
41+
applier = ev.actor.login if ev.actor is not None else None
42+
return applier
43+
944

1045
def check_autorevert_disabled(repo_full_name: str = "pytorch/pytorch") -> bool:
1146
"""
12-
Check if autorevert is disabled by looking for open issues with 'ci: disable-autorevert' label.
47+
Check if autorevert is disabled by looking for open issues with the
48+
'ci: disable-autorevert' label that was applied by a user with write access.
49+
50+
The label alone is not sufficient authority: a user without write
51+
permissions can still get the label onto an issue (e.g. via the issue
52+
template, whose labels GitHub applies on the author's behalf at creation
53+
time). To prevent an unprivileged user from disabling autorevert for the
54+
whole repo, we additionally require the user who applied the label to have
55+
write/maintain/admin permission on the repository.
1356
1457
Args:
1558
repo_full_name: Repository name in format 'owner/repo'
@@ -23,26 +66,58 @@ def check_autorevert_disabled(repo_full_name: str = "pytorch/pytorch") -> bool:
2366
gh_client = GHClientFactory().client
2467
repo = gh_client.get_repo(repo_full_name)
2568

26-
should_disable = False
27-
2869
# Search for open issues with the specific label
2970
disable_issues = repo.get_issues(
30-
state="open", labels=["ci: disable-autorevert"]
71+
state="open", labels=[DISABLE_AUTOREVERT_LABEL]
3172
)
3273

3374
for issue in disable_issues:
75+
try:
76+
applier = _label_applier_login(issue, DISABLE_AUTOREVERT_LABEL)
77+
permission = (
78+
repo.get_collaborator_permission(applier)
79+
if applier is not None
80+
else None
81+
)
82+
except Exception as e:
83+
# Fail safe: if we cannot positively confirm the label
84+
# was applied by a write-access user, do NOT disable
85+
# autorevert. Skip this issue, keep evaluating the rest.
86+
logger.warning(
87+
f"Could not resolve the '{DISABLE_AUTOREVERT_LABEL}' "
88+
f"applier permission on issue #{issue.number}: {e}. "
89+
f"Ignoring this issue for the autorevert circuit breaker."
90+
)
91+
continue
92+
93+
if applier is None:
94+
logger.warning(
95+
f"Could not determine who applied "
96+
f"'{DISABLE_AUTOREVERT_LABEL}' to issue #{issue.number}; "
97+
f"ignoring it for the autorevert circuit breaker."
98+
)
99+
continue
100+
101+
if permission not in _AUTHORIZED_PERMISSIONS:
102+
logger.warning(
103+
f"Ignoring open issue #{issue.number}: "
104+
f"'{DISABLE_AUTOREVERT_LABEL}' was applied by {applier} "
105+
f"with '{permission}' permission (write access required "
106+
f"to disable autorevert)."
107+
)
108+
continue
109+
34110
logger.info(
35-
f"Found open issue #{issue.number} with 'ci: disable-autorevert' label "
36-
f"created by user {issue.user.login}. "
111+
f"Found open issue #{issue.number} with "
112+
f"'{DISABLE_AUTOREVERT_LABEL}' applied by {applier} "
113+
f"('{permission}' permission). "
37114
f"Autorevert circuit breaker is ACTIVE."
38115
)
39-
should_disable = True
40-
41-
if should_disable:
42116
return True
43117

44118
logger.debug(
45-
"No open issues with 'ci: disable-autorevert' label found."
119+
f"No open issues with '{DISABLE_AUTOREVERT_LABEL}' applied by a "
120+
f"write-access user found."
46121
)
47122
return False
48123

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import sys
2+
import unittest
3+
from unittest.mock import MagicMock, patch
4+
5+
6+
# Ensure package import when running from repo root
7+
sys.path.insert(0, "aws/lambda/pytorch-auto-revert")
8+
9+
from pytorch_auto_revert import autorevert_circuit_breaker # noqa: E402
10+
from pytorch_auto_revert.autorevert_circuit_breaker import ( # noqa: E402
11+
check_autorevert_disabled,
12+
DISABLE_AUTOREVERT_LABEL,
13+
)
14+
15+
16+
def _event(event_type, label_name=None, actor_login=None):
17+
ev = MagicMock()
18+
ev.event = event_type
19+
if label_name is None:
20+
ev.label = None
21+
else:
22+
ev.label = MagicMock()
23+
ev.label.name = label_name
24+
if actor_login is None:
25+
ev.actor = None
26+
else:
27+
ev.actor = MagicMock()
28+
ev.actor.login = actor_login
29+
return ev
30+
31+
32+
def _make_issue(number, events):
33+
"""events: list of (event_type, label_name|None, actor_login|None)."""
34+
issue = MagicMock()
35+
issue.number = number
36+
issue.user = MagicMock()
37+
issue.user.login = "issue-author"
38+
issue.get_events.return_value = [_event(*e) for e in events]
39+
return issue
40+
41+
42+
def _labeled_issue(number, applier_login):
43+
"""An issue whose disable-autorevert label was applied by applier_login."""
44+
return _make_issue(number, [("labeled", DISABLE_AUTOREVERT_LABEL, applier_login)])
45+
46+
47+
class TestCheckAutorevertDisabled(unittest.TestCase):
48+
def _run_with(self, issues, permission_map, perm_side_effect=None):
49+
"""Run check_autorevert_disabled against a mocked GitHub repo.
50+
51+
issues: list of mock issues returned by repo.get_issues
52+
permission_map: dict applier-login -> permission string
53+
perm_side_effect: optional callable(login) used instead of the map
54+
"""
55+
repo = MagicMock()
56+
repo.get_issues.return_value = issues
57+
58+
if perm_side_effect is not None:
59+
repo.get_collaborator_permission.side_effect = perm_side_effect
60+
else:
61+
repo.get_collaborator_permission.side_effect = lambda login: permission_map[
62+
login
63+
]
64+
65+
client = MagicMock()
66+
client.get_repo.return_value = repo
67+
68+
factory = MagicMock()
69+
factory.client = client
70+
71+
with patch.object(
72+
autorevert_circuit_breaker, "GHClientFactory", return_value=factory
73+
):
74+
result = check_autorevert_disabled("pytorch/pytorch")
75+
return result, repo
76+
77+
def test_no_issues_returns_false(self):
78+
result, repo = self._run_with([], {})
79+
self.assertFalse(result)
80+
repo.get_collaborator_permission.assert_not_called()
81+
82+
def test_write_access_applier_disables(self):
83+
result, _ = self._run_with(
84+
[_labeled_issue(1, "maintainer")], {"maintainer": "write"}
85+
)
86+
self.assertTrue(result)
87+
88+
def test_admin_and_maintain_appliers_disable(self):
89+
for perm in ("admin", "maintain"):
90+
with self.subTest(perm=perm):
91+
result, _ = self._run_with([_labeled_issue(1, "boss")], {"boss": perm})
92+
self.assertTrue(result)
93+
94+
def test_unprivileged_applier_does_not_disable(self):
95+
# The exploit case: a NONE/read user trips the label via the template,
96+
# so the "labeled" event's actor is that unprivileged user.
97+
for perm in ("none", "read"):
98+
with self.subTest(perm=perm):
99+
result, _ = self._run_with(
100+
[_labeled_issue(188383, "shameelvk9-png")],
101+
{"shameelvk9-png": perm},
102+
)
103+
self.assertFalse(result)
104+
105+
def test_triage_applier_does_not_disable(self):
106+
# Triagers can manage labels but must not be able to disable autorevert.
107+
result, _ = self._run_with(
108+
[_labeled_issue(1, "triager")], {"triager": "triage"}
109+
)
110+
self.assertFalse(result)
111+
112+
def test_maintainer_labeling_unprivileged_authored_issue_disables(self):
113+
# Author is unprivileged; a maintainer applied the label. This must be
114+
# honored (the author-permission approach would wrongly ignore it).
115+
issue = _make_issue(1, [("labeled", DISABLE_AUTOREVERT_LABEL, "maintainer")])
116+
issue.user.login = "random-contributor"
117+
result, _ = self._run_with([issue], {"maintainer": "write"})
118+
self.assertTrue(result)
119+
120+
def test_latest_applier_wins_after_relabel(self):
121+
# Removed then re-applied by an unprivileged user -> latest applier (none) governs.
122+
issue = _make_issue(
123+
1,
124+
[
125+
("labeled", DISABLE_AUTOREVERT_LABEL, "maintainer"),
126+
("unlabeled", DISABLE_AUTOREVERT_LABEL, "maintainer"),
127+
("labeled", DISABLE_AUTOREVERT_LABEL, "rando"),
128+
],
129+
)
130+
result, _ = self._run_with([issue], {"maintainer": "write", "rando": "none"})
131+
self.assertFalse(result)
132+
133+
def test_no_labeled_event_is_ignored(self):
134+
# Label present but no "labeled" event for it (anomalous) -> fail safe.
135+
issue = _make_issue(
136+
1, [("closed", None, "someone"), ("assigned", None, "someone")]
137+
)
138+
result, repo = self._run_with([issue], {})
139+
self.assertFalse(result)
140+
repo.get_collaborator_permission.assert_not_called()
141+
142+
def test_unprivileged_then_privileged_keeps_evaluating(self):
143+
result, _ = self._run_with(
144+
[_labeled_issue(1, "rando"), _labeled_issue(2, "maintainer")],
145+
{"rando": "none", "maintainer": "write"},
146+
)
147+
self.assertTrue(result)
148+
149+
def test_get_events_error_is_skipped_fail_safe(self):
150+
issue = _labeled_issue(1, "maintainer")
151+
issue.get_events.side_effect = RuntimeError("github api hiccup")
152+
result, _ = self._run_with([issue], {"maintainer": "write"})
153+
self.assertFalse(result)
154+
155+
def test_permission_lookup_error_is_skipped_fail_safe(self):
156+
def boom(login):
157+
raise RuntimeError("github api hiccup")
158+
159+
result, _ = self._run_with(
160+
[_labeled_issue(1, "rando")], {}, perm_side_effect=boom
161+
)
162+
self.assertFalse(result)
163+
164+
def test_permission_error_on_one_issue_does_not_block_authorized_one(self):
165+
def perm(login):
166+
if login == "rando":
167+
raise RuntimeError("github api hiccup")
168+
return "admin"
169+
170+
result, _ = self._run_with(
171+
[_labeled_issue(1, "rando"), _labeled_issue(2, "maintainer")],
172+
{},
173+
perm_side_effect=perm,
174+
)
175+
self.assertTrue(result)
176+
177+
178+
if __name__ == "__main__":
179+
unittest.main()

0 commit comments

Comments
 (0)