diff --git a/tests/unit/malware/checks/package_turnover/__init__.py b/tests/unit/malware/checks/package_turnover/__init__.py new file mode 100644 index 000000000000..164f68b09175 --- /dev/null +++ b/tests/unit/malware/checks/package_turnover/__init__.py @@ -0,0 +1,11 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/unit/malware/checks/package_turnover/test_check.py b/tests/unit/malware/checks/package_turnover/test_check.py new file mode 100644 index 000000000000..4215b89d0d66 --- /dev/null +++ b/tests/unit/malware/checks/package_turnover/test_check.py @@ -0,0 +1,177 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pretend + +from warehouse.malware.checks.package_turnover import check as c +from warehouse.malware.models import ( + MalwareCheckState, + VerdictClassification, + VerdictConfidence, +) + +from .....common.db.accounts import UserFactory +from .....common.db.malware import MalwareCheckFactory +from .....common.db.packaging import ProjectFactory, ReleaseFactory + + +def test_initializes(db_session): + check_model = MalwareCheckFactory.create( + name="PackageTurnoverCheck", state=MalwareCheckState.Enabled, + ) + check = c.PackageTurnoverCheck(db_session) + + assert check.id == check_model.id + + +def test_user_posture_verdicts(db_session): + user = UserFactory.create() + project = pretend.stub(users=[user], id=pretend.stub()) + + MalwareCheckFactory.create( + name="PackageTurnoverCheck", state=MalwareCheckState.Enabled, + ) + check = c.PackageTurnoverCheck(db_session) + + user.record_event( + tag="account:two_factor:method_removed", ip_address="0.0.0.0", additional={} + ) + + check.user_posture_verdicts(project) + assert len(check._verdicts) == 1 + assert check._verdicts[0].check_id == check.id + assert check._verdicts[0].project_id == project.id + assert check._verdicts[0].classification == VerdictClassification.Threat + assert check._verdicts[0].confidence == VerdictConfidence.High + assert ( + check._verdicts[0].message + == "User with control over this package has disabled 2FA" + ) + + +def test_user_posture_verdicts_hasnt_removed_2fa(db_session): + user = UserFactory.create() + project = pretend.stub(users=[user], id=pretend.stub()) + + MalwareCheckFactory.create( + name="PackageTurnoverCheck", state=MalwareCheckState.Enabled, + ) + check = c.PackageTurnoverCheck(db_session) + + check.user_posture_verdicts(project) + assert len(check._verdicts) == 0 + + +def test_user_posture_verdicts_has_2fa(db_session): + user = UserFactory.create(totp_secret=b"fake secret") + project = pretend.stub(users=[user], id=pretend.stub()) + + MalwareCheckFactory.create( + name="PackageTurnoverCheck", state=MalwareCheckState.Enabled, + ) + check = c.PackageTurnoverCheck(db_session) + + user.record_event( + tag="account:two_factor:method_removed", ip_address="0.0.0.0", additional={} + ) + + check.user_posture_verdicts(project) + assert len(check._verdicts) == 0 + + +def test_user_turnover_verdicts(db_session): + user = UserFactory.create() + project = ProjectFactory.create(users=[user]) + + project.record_event( + tag="project:role:add", + ip_address="0.0.0.0", + additional={"target_user": user.username}, + ) + + MalwareCheckFactory.create( + name="PackageTurnoverCheck", state=MalwareCheckState.Enabled, + ) + check = c.PackageTurnoverCheck(db_session) + + check.user_turnover_verdicts(project) + assert len(check._verdicts) == 1 + assert check._verdicts[0].check_id == check.id + assert check._verdicts[0].project_id == project.id + assert check._verdicts[0].classification == VerdictClassification.Threat + assert check._verdicts[0].confidence == VerdictConfidence.High + assert ( + check._verdicts[0].message + == "Suspicious user turnover; all current maintainers are new" + ) + + +def test_user_turnover_verdicts_no_turnover(db_session): + user = UserFactory.create() + project = ProjectFactory.create(users=[user]) + + MalwareCheckFactory.create( + name="PackageTurnoverCheck", state=MalwareCheckState.Enabled, + ) + check = c.PackageTurnoverCheck(db_session) + + check.user_turnover_verdicts(project) + assert len(check._verdicts) == 0 + + +def test_scan(db_session, monkeypatch): + user = UserFactory.create() + project = ProjectFactory.create(users=[user]) + + for _ in range(3): + ReleaseFactory.create(project=project) + + MalwareCheckFactory.create( + name="PackageTurnoverCheck", state=MalwareCheckState.Enabled, + ) + check = c.PackageTurnoverCheck(db_session) + + monkeypatch.setattr( + check, "user_posture_verdicts", pretend.call_recorder(lambda project: None) + ) + monkeypatch.setattr( + check, "user_turnover_verdicts", pretend.call_recorder(lambda project: None) + ) + + check.scan() + + # Each verdict rendering method is only called once per project, + # thanks to deduplication. + assert check.user_posture_verdicts.calls == [pretend.call(project)] + assert check.user_turnover_verdicts.calls == [pretend.call(project)] + + +def test_scan_too_few_releases(db_session, monkeypatch): + user = UserFactory.create() + project = ProjectFactory.create(users=[user]) + ReleaseFactory.create(project=project) + + MalwareCheckFactory.create( + name="PackageTurnoverCheck", state=MalwareCheckState.Enabled, + ) + check = c.PackageTurnoverCheck(db_session) + + monkeypatch.setattr( + check, "user_posture_verdicts", pretend.call_recorder(lambda project: None) + ) + monkeypatch.setattr( + check, "user_turnover_verdicts", pretend.call_recorder(lambda project: None) + ) + + check.scan() + assert check.user_posture_verdicts.calls == [] + assert check.user_turnover_verdicts.calls == [] diff --git a/tests/unit/malware/test_tasks.py b/tests/unit/malware/test_tasks.py index ec8f5e8dac48..f2e77afe25f5 100644 --- a/tests/unit/malware/test_tasks.py +++ b/tests/unit/malware/test_tasks.py @@ -85,9 +85,7 @@ def test_disabled_check(self, db_session, monkeypatch): file = FileFactory.create() - tasks.run_check( - task, request, "ExampleHookedCheck", obj_id=file.id, - ) + tasks.run_check(task, request, "ExampleHookedCheck", obj_id=file.id) assert request.log.info.calls == [ pretend.call("Check ExampleHookedCheck isn't active. Aborting.") @@ -98,9 +96,7 @@ def test_missing_check(self, db_request, monkeypatch): task = pretend.stub() with pytest.raises(AttributeError): - tasks.run_check( - task, db_request, "DoesNotExistCheck", - ) + tasks.run_check(task, db_request, "DoesNotExistCheck") def test_missing_obj_id(self, db_session, monkeypatch): monkeypatch.setattr(tasks, "checks", test_checks) diff --git a/warehouse/malware/checks/__init__.py b/warehouse/malware/checks/__init__.py index fa0607f15913..ea686b348b5e 100644 --- a/warehouse/malware/checks/__init__.py +++ b/warehouse/malware/checks/__init__.py @@ -10,4 +10,5 @@ # See the License for the specific language governing permissions and # limitations under the License. +from .package_turnover import PackageTurnoverCheck # noqa from .setup_patterns import SetupPatternCheck # noqa diff --git a/warehouse/malware/checks/package_turnover/__init__.py b/warehouse/malware/checks/package_turnover/__init__.py new file mode 100644 index 000000000000..e3d7d35259ee --- /dev/null +++ b/warehouse/malware/checks/package_turnover/__init__.py @@ -0,0 +1,13 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from .check import PackageTurnoverCheck # noqa diff --git a/warehouse/malware/checks/package_turnover/check.py b/warehouse/malware/checks/package_turnover/check.py new file mode 100644 index 000000000000..91fdb513958a --- /dev/null +++ b/warehouse/malware/checks/package_turnover/check.py @@ -0,0 +1,112 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import datetime, timedelta +from textwrap import dedent + +from warehouse.accounts.models import UserEvent +from warehouse.malware.checks.base import MalwareCheckBase +from warehouse.malware.models import ( + MalwareVerdict, + VerdictClassification, + VerdictConfidence, +) +from warehouse.packaging.models import ProjectEvent, Release + + +class PackageTurnoverCheck(MalwareCheckBase): + version = 1 + short_description = "A check for unusual changes in package ownership" + long_description = dedent( + """ + This check looks at recently uploaded releases and determines + whether their owners have recently changed or decreased the security + of their accounts (e.g., by disabling 2FA). + """ + ) + check_type = "scheduled" + schedule = {"minute": 0, "hour": 0} + + def __init__(self, db): + super().__init__(db) + self._scan_interval = datetime.utcnow() - timedelta(hours=24) + + def user_posture_verdicts(self, project): + for user in project.users: + has_removed_2fa_method = self.db.query( + self.db.query(UserEvent) + .filter(UserEvent.user_id == user.id) + .filter(UserEvent.time >= self._scan_interval) + .filter(UserEvent.tag == "account:two_factor:method_removed") + .exists() + ).scalar() + + if has_removed_2fa_method and not user.has_two_factor: + self.add_verdict( + project_id=project.id, + classification=VerdictClassification.Threat, + confidence=VerdictConfidence.High, + message="User with control over this package has disabled 2FA", + ) + + def user_turnover_verdicts(self, project): + # NOTE: This could probably be more involved to check for the case + # where someone adds themself, removes the real maintainers, pushes a malicious + # release, then reverts the ownership to the original maintainers and removes + # themself again. + recent_role_adds = ( + self.db.query(ProjectEvent.additional) + .filter(ProjectEvent.project_id == project.id) + .filter(ProjectEvent.time >= self._scan_interval) + .filter(ProjectEvent.tag == "project:role:add") + .all() + ) + + added_users = {role_add["target_user"] for role_add, in recent_role_adds} + current_users = {user.username for user in project.users} + + if added_users == current_users: + self.add_verdict( + project_id=project.id, + classification=VerdictClassification.Threat, + confidence=VerdictConfidence.High, + message="Suspicious user turnover; all current maintainers are new", + ) + + def scan(self, **kwargs): + prior_verdicts = ( + self.db.query(MalwareVerdict.release_id).filter( + MalwareVerdict.check_id == self.id + ) + ).subquery() + + releases = ( + self.db.query(Release) + .filter(Release.created >= self._scan_interval) + .filter(~Release.id.in_(prior_verdicts)) + .all() + ) + + visited_project_ids = set() + for release in releases: + # Skip projects for which this is the first release, + # since we need a baseline to compare against + if len(release.project.releases) < 2: + continue + + if release.project.id in visited_project_ids: + continue + + visited_project_ids.add(release.project.id) + + self.user_posture_verdicts(release.project) + self.user_turnover_verdicts(release.project)