Skip to content

Commit f46c35f

Browse files
authored
feat: automatically quarantine project via task (#17412)
1 parent 7c2bf39 commit f46c35f

File tree

11 files changed

+501
-21
lines changed

11 files changed

+501
-21
lines changed

dev/environment

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,6 @@ HCAPTCHA_SECRET_KEY=0x0000000000000000000000000000000000000000
8484
# HELPSCOUT_WAREHOUSE_APP_SECRET="an insecure helpscout app secret"
8585
# HELPSCOUT_WAREHOUSE_MAILBOX_ID=123456789
8686
HELPDESK_BACKEND="warehouse.helpdesk.services.ConsoleHelpDeskService"
87+
88+
# HELPDESK_NOTIFICATION_SERVICE_URL="https://..."
89+
HELPDESK_NOTIFICATION_BACKEND="warehouse.helpdesk.services.ConsoleAdminNotificationService"

tests/conftest.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
from warehouse.email import services as email_services
5252
from warehouse.email.interfaces import IEmailSender
5353
from warehouse.helpdesk import services as helpdesk_services
54-
from warehouse.helpdesk.interfaces import IHelpDeskService
54+
from warehouse.helpdesk.interfaces import IAdminNotificationService, IHelpDeskService
5555
from warehouse.macaroons import services as macaroon_services
5656
from warehouse.macaroons.interfaces import IMacaroonService
5757
from warehouse.metrics import IMetricsService
@@ -183,6 +183,7 @@ def pyramid_services(
183183
integrity_service,
184184
macaroon_service,
185185
helpdesk_service,
186+
notification_service,
186187
):
187188
services = _Services()
188189

@@ -205,6 +206,7 @@ def pyramid_services(
205206
services.register_service(integrity_service, IIntegrityService, None)
206207
services.register_service(macaroon_service, IMacaroonService, None, name="")
207208
services.register_service(helpdesk_service, IHelpDeskService, None)
209+
services.register_service(notification_service, IAdminNotificationService)
208210

209211
return services
210212

@@ -230,6 +232,11 @@ def pyramid_request(pyramid_services, jinja, remote_addr, remote_addr_hashed):
230232
dummy_request.task = pretend.call_recorder(
231233
lambda *a, **kw: dummy_request._task_stub
232234
)
235+
dummy_request.log = pretend.stub(
236+
bind=pretend.call_recorder(lambda *args, **kwargs: dummy_request.log),
237+
info=pretend.call_recorder(lambda *args, **kwargs: None),
238+
error=pretend.call_recorder(lambda *args, **kwargs: None),
239+
)
233240

234241
def localize(message, **kwargs):
235242
ts = TranslationString(message, **kwargs)
@@ -339,6 +346,7 @@ def get_app_config(database, nondefaults=None):
339346
"billing.api_version": "2020-08-27",
340347
"mail.backend": "warehouse.email.services.SMTPEmailSender",
341348
"helpdesk.backend": "warehouse.helpdesk.services.ConsoleHelpDeskService",
349+
"helpdesk.notification_backend": "warehouse.helpdesk.services.ConsoleHelpDeskService", # noqa: E501
342350
"files.url": "http://localhost:7000/",
343351
"archive_files.url": "http://localhost:7000/archive",
344352
"sessions.secret": "123456",
@@ -616,6 +624,11 @@ def helpdesk_service():
616624
return helpdesk_services.ConsoleHelpDeskService()
617625

618626

627+
@pytest.fixture
628+
def notification_service():
629+
return helpdesk_services.ConsoleAdminNotificationService()
630+
631+
619632
class QueryRecorder:
620633
def __init__(self):
621634
self.queries = []

tests/unit/helpdesk/test_init.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,29 @@
1313
import pretend
1414

1515
from warehouse.helpdesk import includeme
16-
from warehouse.helpdesk.interfaces import IHelpDeskService
16+
from warehouse.helpdesk.interfaces import IAdminNotificationService, IHelpDeskService
1717

1818

1919
def test_includeme():
20-
helpdesk_class = pretend.stub(create_service=pretend.stub())
20+
dummy_klass = pretend.stub(create_service=pretend.stub())
2121
config = pretend.stub(
22-
registry=pretend.stub(settings={"helpdesk.backend": "tests.CustomBackend"}),
23-
maybe_dotted=pretend.call_recorder(lambda n: helpdesk_class),
22+
registry=pretend.stub(
23+
settings={
24+
"helpdesk.backend": "test.HelpDeskService",
25+
"helpdesk.notification_backend": "test.NotificationService",
26+
}
27+
),
28+
maybe_dotted=pretend.call_recorder(lambda n: dummy_klass),
2429
register_service_factory=pretend.call_recorder(lambda s, i, **kw: None),
2530
)
2631

2732
includeme(config)
2833

29-
assert config.maybe_dotted.calls == [pretend.call("tests.CustomBackend")]
34+
assert config.maybe_dotted.calls == [
35+
pretend.call("test.HelpDeskService"),
36+
pretend.call("test.NotificationService"),
37+
]
3038
assert config.register_service_factory.calls == [
31-
pretend.call(helpdesk_class.create_service, IHelpDeskService)
39+
pretend.call(dummy_klass.create_service, IHelpDeskService),
40+
pretend.call(dummy_klass.create_service, IAdminNotificationService),
3241
]

tests/unit/helpdesk/test_services.py

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,13 @@
2222
from pyramid_retry import RetryableException
2323
from zope.interface.verify import verifyClass
2424

25-
from warehouse.helpdesk.interfaces import IHelpDeskService
26-
from warehouse.helpdesk.services import ConsoleHelpDeskService, HelpScoutService
25+
from warehouse.helpdesk.interfaces import IAdminNotificationService, IHelpDeskService
26+
from warehouse.helpdesk.services import (
27+
ConsoleAdminNotificationService,
28+
ConsoleHelpDeskService,
29+
HelpScoutService,
30+
SlackAdminNotificationService,
31+
)
2732

2833

2934
@pytest.mark.parametrize("service_class", [ConsoleHelpDeskService, HelpScoutService])
@@ -217,3 +222,70 @@ def test_add_tag_with_duplicate(self):
217222

218223
# No PUT call should be made
219224
assert len(responses.calls) == 1
225+
226+
227+
@pytest.mark.parametrize(
228+
"service_class", [ConsoleAdminNotificationService, SlackAdminNotificationService]
229+
)
230+
class TestAdminNotificationService:
231+
"""Common tests for the service interface."""
232+
233+
def test_verify_service_class(self, service_class):
234+
assert verifyClass(IAdminNotificationService, service_class)
235+
236+
def test_create_service(self, service_class):
237+
context = None
238+
request = pretend.stub(
239+
http=pretend.stub(),
240+
log=pretend.stub(
241+
debug=pretend.call_recorder(lambda msg: None),
242+
),
243+
registry=pretend.stub(
244+
settings={
245+
"helpdesk.notification_service_url": "https://webhook.example/1234",
246+
}
247+
),
248+
)
249+
250+
service = service_class.create_service(context, request)
251+
assert isinstance(service, service_class)
252+
253+
254+
class TestConsoleAdminNotificationService:
255+
def test_send_notification(self, capsys):
256+
service = ConsoleAdminNotificationService()
257+
258+
service.send_notification(payload={"text": "Hello, World!"})
259+
260+
captured = capsys.readouterr()
261+
262+
expected = dedent(
263+
"""\
264+
Webhook notification sent
265+
payload:
266+
{'text': 'Hello, World!'}
267+
"""
268+
)
269+
assert captured.out == expected
270+
271+
272+
class TestSlackAdminNotificationService:
273+
@responses.activate
274+
def test_send_notification(self):
275+
responses.add(
276+
responses.POST,
277+
"https://webhook.example/1234",
278+
json={"ok": True},
279+
)
280+
281+
service = SlackAdminNotificationService(
282+
session=requests.Session(),
283+
webhook_url="https://webhook.example/1234",
284+
)
285+
286+
service.send_notification(payload={"text": "Hello, World!"})
287+
288+
assert len(responses.calls) == 1
289+
post_call = responses.calls[0]
290+
assert post_call.request.url == "https://webhook.example/1234"
291+
assert post_call.response.json() == {"ok": True}

tests/unit/observations/test_tasks.py

Lines changed: 165 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@
1515

1616
from warehouse.observations.models import ObservationKind
1717
from warehouse.observations.tasks import (
18-
execute_observation_report,
18+
evaluate_project_for_quarantine,
19+
react_to_observation_created,
1920
report_observation_to_helpscout,
2021
)
22+
from warehouse.packaging.models import LifecycleStatus
2123

2224
from ...common.db.accounts import UserFactory
23-
from ...common.db.packaging import ProjectFactory, RoleFactory
25+
from ...common.db.packaging import ProjectFactory, ReleaseFactory, RoleFactory
2426

2527

2628
def test_execute_observation_report(app_config):
@@ -29,9 +31,9 @@ def test_execute_observation_report(app_config):
2931
observation = pretend.stub(id=pretend.stub())
3032
session = pretend.stub(info={"warehouse.observations.new": {observation}})
3133

32-
execute_observation_report(app_config, session)
34+
react_to_observation_created(app_config, session)
3335

34-
assert _delay.calls == [pretend.call(observation.id)]
36+
assert _delay.calls == [pretend.call(observation.id), pretend.call(observation.id)]
3537

3638

3739
@pytest.mark.parametrize(
@@ -75,3 +77,162 @@ def test_report_observation_to_helpscout(
7577

7678
# If it's not supposed to report, then we shouldn't have called the service
7779
assert bool(hs_svc_spy.calls) == reports
80+
81+
82+
class TestAutoQuarantineProject:
83+
def test_non_malware_observation_does_not_quarantine(self, db_request):
84+
dummy_task = pretend.stub(name="dummy_task")
85+
user = UserFactory.create()
86+
db_request.user = user
87+
project = ProjectFactory.create()
88+
89+
observation = project.record_observation(
90+
request=db_request,
91+
kind=ObservationKind.IsDependencyConfusion,
92+
summary="Project Observation",
93+
payload={},
94+
actor=user,
95+
)
96+
# Need to flush the session to ensure the Observation has an ID
97+
db_request.db.flush()
98+
99+
evaluate_project_for_quarantine(dummy_task, db_request, observation.id)
100+
101+
assert project.lifecycle_status != LifecycleStatus.QuarantineEnter
102+
assert db_request.log.info.calls == [
103+
pretend.call("ObservationKind is not IsMalware. Not quarantining.")
104+
]
105+
106+
def test_already_quarantined_project_does_not_do_anything(self, db_request):
107+
dummy_task = pretend.stub(name="dummy_task")
108+
user = UserFactory.create()
109+
db_request.user = user
110+
project = ProjectFactory.create(
111+
lifecycle_status=LifecycleStatus.QuarantineEnter
112+
)
113+
114+
observation = project.record_observation(
115+
request=db_request,
116+
kind=ObservationKind.IsMalware,
117+
summary="Project Observation",
118+
payload={},
119+
actor=user,
120+
)
121+
# Need to flush the session to ensure the Observation has an ID
122+
db_request.db.flush()
123+
124+
evaluate_project_for_quarantine(dummy_task, db_request, observation.id)
125+
126+
assert project.lifecycle_status == LifecycleStatus.QuarantineEnter
127+
assert db_request.log.info.calls == [
128+
pretend.call("Project is already quarantined. No change needed.")
129+
]
130+
131+
def test_not_enough_observers_does_not_quarantine(self, db_request):
132+
dummy_task = pretend.stub(name="dummy_task")
133+
user = UserFactory.create()
134+
db_request.user = user
135+
project = ProjectFactory.create()
136+
137+
observation = project.record_observation(
138+
request=db_request,
139+
kind=ObservationKind.IsMalware,
140+
summary="Project Observation",
141+
payload={},
142+
actor=user,
143+
)
144+
# Need to flush the session to ensure the Observation has an ID
145+
db_request.db.flush()
146+
147+
evaluate_project_for_quarantine(dummy_task, db_request, observation.id)
148+
149+
assert project.lifecycle_status != LifecycleStatus.QuarantineEnter
150+
assert db_request.log.info.calls == [
151+
pretend.call("Project has fewer than 2 observers. Not quarantining.")
152+
]
153+
154+
def test_no_observer_observers_does_not_quarantine(self, db_request):
155+
dummy_task = pretend.stub(name="dummy_task")
156+
user = UserFactory.create()
157+
db_request.user = user
158+
project = ProjectFactory.create()
159+
160+
another_user = UserFactory.create()
161+
162+
# Record 2 observations, but neither are from an observer
163+
project.record_observation(
164+
request=db_request,
165+
kind=ObservationKind.IsMalware,
166+
summary="Project Observation",
167+
payload={},
168+
actor=user,
169+
)
170+
observation = project.record_observation(
171+
request=db_request,
172+
kind=ObservationKind.IsMalware,
173+
summary="Project Observation",
174+
payload={},
175+
actor=another_user,
176+
)
177+
# Need to flush the session to ensure the Observations has an ID
178+
db_request.db.flush()
179+
180+
evaluate_project_for_quarantine(dummy_task, db_request, observation.id)
181+
182+
assert project.lifecycle_status != LifecycleStatus.QuarantineEnter
183+
assert db_request.log.info.calls == [
184+
pretend.call(
185+
"Project has no `User.is_observer` Observers. Not quarantining."
186+
)
187+
]
188+
189+
def test_quarantines_project(self, db_request, notification_service, monkeypatch):
190+
"""
191+
Satisfies criteria for auto-quarantine:
192+
- 2 observations
193+
- from different observers
194+
- one of which is an Observer
195+
"""
196+
dummy_task = pretend.stub(name="dummy_task")
197+
user = UserFactory.create(is_observer=True)
198+
project = ProjectFactory.create()
199+
# Needs a release to be able to quarantine
200+
ReleaseFactory.create(project=project)
201+
202+
another_user = UserFactory.create()
203+
204+
db_request.route_url = pretend.call_recorder(
205+
lambda *args, **kw: "/project/spam/"
206+
)
207+
db_request.user = user
208+
209+
# Record 2 observations, one from an observer
210+
project.record_observation(
211+
request=db_request,
212+
kind=ObservationKind.IsMalware,
213+
summary="Project Observation",
214+
payload={},
215+
actor=user,
216+
)
217+
observation = project.record_observation(
218+
request=db_request,
219+
kind=ObservationKind.IsMalware,
220+
summary="Project Observation",
221+
payload={},
222+
actor=another_user,
223+
)
224+
# Need to flush the session to ensure the Observation has an ID
225+
db_request.db.flush()
226+
227+
ns_svc_spy = pretend.call_recorder(lambda *args, **kwargs: None)
228+
monkeypatch.setattr(notification_service, "send_notification", ns_svc_spy)
229+
230+
evaluate_project_for_quarantine(dummy_task, db_request, observation.id)
231+
232+
assert len(ns_svc_spy.calls) == 1
233+
assert project.lifecycle_status == LifecycleStatus.QuarantineEnter
234+
assert db_request.log.info.calls == [
235+
pretend.call(
236+
"Auto-quarantining project due to multiple malware observations."
237+
),
238+
]

warehouse/config.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,15 @@ def configure(settings=None):
480480
maybe_set(settings, "helpscout.app_id", "HELPSCOUT_WAREHOUSE_APP_ID")
481481
maybe_set(settings, "helpscout.app_secret", "HELPSCOUT_WAREHOUSE_APP_SECRET")
482482
maybe_set(settings, "helpscout.mailbox_id", "HELPSCOUT_WAREHOUSE_MAILBOX_ID")
483+
# Admin notification service settings
484+
maybe_set(
485+
settings, "helpdesk.notification_backend", "HELPDESK_NOTIFICATION_BACKEND"
486+
)
487+
maybe_set(
488+
settings,
489+
"helpdesk.notification_service_url",
490+
"HELPDESK_NOTIFICATION_SERVICE_URL",
491+
)
483492

484493
# Configure our ratelimiters
485494
maybe_set(

0 commit comments

Comments
 (0)