Skip to content

Commit

Permalink
malware/checks: PackageTurnover skeleton (#7321)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
woodruffw authored and ewdurbin committed Feb 18, 2020
1 parent c1370bf commit f614bad
Show file tree
Hide file tree
Showing 6 changed files with 316 additions and 6 deletions.
11 changes: 11 additions & 0 deletions tests/unit/malware/checks/package_turnover/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
177 changes: 177 additions & 0 deletions tests/unit/malware/checks/package_turnover/test_check.py
Original file line number Diff line number Diff line change
@@ -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 == []
8 changes: 2 additions & 6 deletions tests/unit/malware/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions warehouse/malware/checks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions warehouse/malware/checks/package_turnover/__init__.py
Original file line number Diff line number Diff line change
@@ -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
112 changes: 112 additions & 0 deletions warehouse/malware/checks/package_turnover/check.py
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit f614bad

Please sign in to comment.