Skip to content

Commit 71461a6

Browse files
woodruffwewdurbin
authored andcommitted
malware/checks: PackageTurnover skeleton (#7321)
* malware/checks: PackageTurnover skeleton * malware/checks: PackageTurnover: Add NOTE * malware/checks: PackageTurnoverCheck: more work * tests: blacken * malware/checks: More PackageTurnoverCheck work * malware/checks: Blacken * malware/checks: Blacken * package_turnover: Promote from indeterminate to threat * tests: Begin adding package_turnover tests * tests: Add remaining package_turnover tests * tests: Drop unused imports * warehouse: Drop (ww) from NOTE * checks/package_turnover: Drop NOTE
1 parent 0b88790 commit 71461a6

File tree

6 files changed

+316
-6
lines changed

6 files changed

+316
-6
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
import pretend
14+
15+
from warehouse.malware.checks.package_turnover import check as c
16+
from warehouse.malware.models import (
17+
MalwareCheckState,
18+
VerdictClassification,
19+
VerdictConfidence,
20+
)
21+
22+
from .....common.db.accounts import UserFactory
23+
from .....common.db.malware import MalwareCheckFactory
24+
from .....common.db.packaging import ProjectFactory, ReleaseFactory
25+
26+
27+
def test_initializes(db_session):
28+
check_model = MalwareCheckFactory.create(
29+
name="PackageTurnoverCheck", state=MalwareCheckState.Enabled,
30+
)
31+
check = c.PackageTurnoverCheck(db_session)
32+
33+
assert check.id == check_model.id
34+
35+
36+
def test_user_posture_verdicts(db_session):
37+
user = UserFactory.create()
38+
project = pretend.stub(users=[user], id=pretend.stub())
39+
40+
MalwareCheckFactory.create(
41+
name="PackageTurnoverCheck", state=MalwareCheckState.Enabled,
42+
)
43+
check = c.PackageTurnoverCheck(db_session)
44+
45+
user.record_event(
46+
tag="account:two_factor:method_removed", ip_address="0.0.0.0", additional={}
47+
)
48+
49+
check.user_posture_verdicts(project)
50+
assert len(check._verdicts) == 1
51+
assert check._verdicts[0].check_id == check.id
52+
assert check._verdicts[0].project_id == project.id
53+
assert check._verdicts[0].classification == VerdictClassification.Threat
54+
assert check._verdicts[0].confidence == VerdictConfidence.High
55+
assert (
56+
check._verdicts[0].message
57+
== "User with control over this package has disabled 2FA"
58+
)
59+
60+
61+
def test_user_posture_verdicts_hasnt_removed_2fa(db_session):
62+
user = UserFactory.create()
63+
project = pretend.stub(users=[user], id=pretend.stub())
64+
65+
MalwareCheckFactory.create(
66+
name="PackageTurnoverCheck", state=MalwareCheckState.Enabled,
67+
)
68+
check = c.PackageTurnoverCheck(db_session)
69+
70+
check.user_posture_verdicts(project)
71+
assert len(check._verdicts) == 0
72+
73+
74+
def test_user_posture_verdicts_has_2fa(db_session):
75+
user = UserFactory.create(totp_secret=b"fake secret")
76+
project = pretend.stub(users=[user], id=pretend.stub())
77+
78+
MalwareCheckFactory.create(
79+
name="PackageTurnoverCheck", state=MalwareCheckState.Enabled,
80+
)
81+
check = c.PackageTurnoverCheck(db_session)
82+
83+
user.record_event(
84+
tag="account:two_factor:method_removed", ip_address="0.0.0.0", additional={}
85+
)
86+
87+
check.user_posture_verdicts(project)
88+
assert len(check._verdicts) == 0
89+
90+
91+
def test_user_turnover_verdicts(db_session):
92+
user = UserFactory.create()
93+
project = ProjectFactory.create(users=[user])
94+
95+
project.record_event(
96+
tag="project:role:add",
97+
ip_address="0.0.0.0",
98+
additional={"target_user": user.username},
99+
)
100+
101+
MalwareCheckFactory.create(
102+
name="PackageTurnoverCheck", state=MalwareCheckState.Enabled,
103+
)
104+
check = c.PackageTurnoverCheck(db_session)
105+
106+
check.user_turnover_verdicts(project)
107+
assert len(check._verdicts) == 1
108+
assert check._verdicts[0].check_id == check.id
109+
assert check._verdicts[0].project_id == project.id
110+
assert check._verdicts[0].classification == VerdictClassification.Threat
111+
assert check._verdicts[0].confidence == VerdictConfidence.High
112+
assert (
113+
check._verdicts[0].message
114+
== "Suspicious user turnover; all current maintainers are new"
115+
)
116+
117+
118+
def test_user_turnover_verdicts_no_turnover(db_session):
119+
user = UserFactory.create()
120+
project = ProjectFactory.create(users=[user])
121+
122+
MalwareCheckFactory.create(
123+
name="PackageTurnoverCheck", state=MalwareCheckState.Enabled,
124+
)
125+
check = c.PackageTurnoverCheck(db_session)
126+
127+
check.user_turnover_verdicts(project)
128+
assert len(check._verdicts) == 0
129+
130+
131+
def test_scan(db_session, monkeypatch):
132+
user = UserFactory.create()
133+
project = ProjectFactory.create(users=[user])
134+
135+
for _ in range(3):
136+
ReleaseFactory.create(project=project)
137+
138+
MalwareCheckFactory.create(
139+
name="PackageTurnoverCheck", state=MalwareCheckState.Enabled,
140+
)
141+
check = c.PackageTurnoverCheck(db_session)
142+
143+
monkeypatch.setattr(
144+
check, "user_posture_verdicts", pretend.call_recorder(lambda project: None)
145+
)
146+
monkeypatch.setattr(
147+
check, "user_turnover_verdicts", pretend.call_recorder(lambda project: None)
148+
)
149+
150+
check.scan()
151+
152+
# Each verdict rendering method is only called once per project,
153+
# thanks to deduplication.
154+
assert check.user_posture_verdicts.calls == [pretend.call(project)]
155+
assert check.user_turnover_verdicts.calls == [pretend.call(project)]
156+
157+
158+
def test_scan_too_few_releases(db_session, monkeypatch):
159+
user = UserFactory.create()
160+
project = ProjectFactory.create(users=[user])
161+
ReleaseFactory.create(project=project)
162+
163+
MalwareCheckFactory.create(
164+
name="PackageTurnoverCheck", state=MalwareCheckState.Enabled,
165+
)
166+
check = c.PackageTurnoverCheck(db_session)
167+
168+
monkeypatch.setattr(
169+
check, "user_posture_verdicts", pretend.call_recorder(lambda project: None)
170+
)
171+
monkeypatch.setattr(
172+
check, "user_turnover_verdicts", pretend.call_recorder(lambda project: None)
173+
)
174+
175+
check.scan()
176+
assert check.user_posture_verdicts.calls == []
177+
assert check.user_turnover_verdicts.calls == []

tests/unit/malware/test_tasks.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,7 @@ def test_disabled_check(self, db_session, monkeypatch):
8585

8686
file = FileFactory.create()
8787

88-
tasks.run_check(
89-
task, request, "ExampleHookedCheck", obj_id=file.id,
90-
)
88+
tasks.run_check(task, request, "ExampleHookedCheck", obj_id=file.id)
9189

9290
assert request.log.info.calls == [
9391
pretend.call("Check ExampleHookedCheck isn't active. Aborting.")
@@ -98,9 +96,7 @@ def test_missing_check(self, db_request, monkeypatch):
9896
task = pretend.stub()
9997

10098
with pytest.raises(AttributeError):
101-
tasks.run_check(
102-
task, db_request, "DoesNotExistCheck",
103-
)
99+
tasks.run_check(task, db_request, "DoesNotExistCheck")
104100

105101
def test_missing_obj_id(self, db_session, monkeypatch):
106102
monkeypatch.setattr(tasks, "checks", test_checks)

warehouse/malware/checks/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@
1010
# See the License for the specific language governing permissions and
1111
# limitations under the License.
1212

13+
from .package_turnover import PackageTurnoverCheck # noqa
1314
from .setup_patterns import SetupPatternCheck # noqa
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
from .check import PackageTurnoverCheck # noqa
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
from datetime import datetime, timedelta
14+
from textwrap import dedent
15+
16+
from warehouse.accounts.models import UserEvent
17+
from warehouse.malware.checks.base import MalwareCheckBase
18+
from warehouse.malware.models import (
19+
MalwareVerdict,
20+
VerdictClassification,
21+
VerdictConfidence,
22+
)
23+
from warehouse.packaging.models import ProjectEvent, Release
24+
25+
26+
class PackageTurnoverCheck(MalwareCheckBase):
27+
version = 1
28+
short_description = "A check for unusual changes in package ownership"
29+
long_description = dedent(
30+
"""
31+
This check looks at recently uploaded releases and determines
32+
whether their owners have recently changed or decreased the security
33+
of their accounts (e.g., by disabling 2FA).
34+
"""
35+
)
36+
check_type = "scheduled"
37+
schedule = {"minute": 0, "hour": 0}
38+
39+
def __init__(self, db):
40+
super().__init__(db)
41+
self._scan_interval = datetime.utcnow() - timedelta(hours=24)
42+
43+
def user_posture_verdicts(self, project):
44+
for user in project.users:
45+
has_removed_2fa_method = self.db.query(
46+
self.db.query(UserEvent)
47+
.filter(UserEvent.user_id == user.id)
48+
.filter(UserEvent.time >= self._scan_interval)
49+
.filter(UserEvent.tag == "account:two_factor:method_removed")
50+
.exists()
51+
).scalar()
52+
53+
if has_removed_2fa_method and not user.has_two_factor:
54+
self.add_verdict(
55+
project_id=project.id,
56+
classification=VerdictClassification.Threat,
57+
confidence=VerdictConfidence.High,
58+
message="User with control over this package has disabled 2FA",
59+
)
60+
61+
def user_turnover_verdicts(self, project):
62+
# NOTE: This could probably be more involved to check for the case
63+
# where someone adds themself, removes the real maintainers, pushes a malicious
64+
# release, then reverts the ownership to the original maintainers and removes
65+
# themself again.
66+
recent_role_adds = (
67+
self.db.query(ProjectEvent.additional)
68+
.filter(ProjectEvent.project_id == project.id)
69+
.filter(ProjectEvent.time >= self._scan_interval)
70+
.filter(ProjectEvent.tag == "project:role:add")
71+
.all()
72+
)
73+
74+
added_users = {role_add["target_user"] for role_add, in recent_role_adds}
75+
current_users = {user.username for user in project.users}
76+
77+
if added_users == current_users:
78+
self.add_verdict(
79+
project_id=project.id,
80+
classification=VerdictClassification.Threat,
81+
confidence=VerdictConfidence.High,
82+
message="Suspicious user turnover; all current maintainers are new",
83+
)
84+
85+
def scan(self, **kwargs):
86+
prior_verdicts = (
87+
self.db.query(MalwareVerdict.release_id).filter(
88+
MalwareVerdict.check_id == self.id
89+
)
90+
).subquery()
91+
92+
releases = (
93+
self.db.query(Release)
94+
.filter(Release.created >= self._scan_interval)
95+
.filter(~Release.id.in_(prior_verdicts))
96+
.all()
97+
)
98+
99+
visited_project_ids = set()
100+
for release in releases:
101+
# Skip projects for which this is the first release,
102+
# since we need a baseline to compare against
103+
if len(release.project.releases) < 2:
104+
continue
105+
106+
if release.project.id in visited_project_ids:
107+
continue
108+
109+
visited_project_ids.add(release.project.id)
110+
111+
self.user_posture_verdicts(release.project)
112+
self.user_turnover_verdicts(release.project)

0 commit comments

Comments
 (0)