diff --git a/tests/common/db/malware.py b/tests/common/db/malware.py index 8c365f4abb66..b6e1bf387b90 100644 --- a/tests/common/db/malware.py +++ b/tests/common/db/malware.py @@ -51,6 +51,12 @@ class Meta: check = factory.SubFactory(MalwareCheckFactory) release_file = factory.SubFactory(FileFactory) + release = None + project = None + manually_reviewed = True + administrator_verdict = factory.fuzzy.FuzzyChoice(list(VerdictClassification)) classification = factory.fuzzy.FuzzyChoice(list(VerdictClassification)) confidence = factory.fuzzy.FuzzyChoice(list(VerdictConfidence)) message = factory.fuzzy.FuzzyText(length=80) + full_report_link = None + details = None diff --git a/tests/common/db/packaging.py b/tests/common/db/packaging.py index 2c55099d0242..ada7eca60831 100644 --- a/tests/common/db/packaging.py +++ b/tests/common/db/packaging.py @@ -83,6 +83,7 @@ class Meta: release = factory.SubFactory(ReleaseFactory) python_version = "source" + filename = factory.fuzzy.FuzzyText(length=12) md5_digest = factory.LazyAttribute( lambda o: hashlib.md5(o.filename.encode("utf8")).hexdigest() ) diff --git a/tests/unit/admin/test_routes.py b/tests/unit/admin/test_routes.py index e10962ac54dc..425d50167529 100644 --- a/tests/unit/admin/test_routes.py +++ b/tests/unit/admin/test_routes.py @@ -132,4 +132,8 @@ def test_includeme(): "/admin/checks/{check_name}/change_state", domain=warehouse, ), + pretend.call("admin.verdicts.list", "/admin/verdicts/", domain=warehouse), + pretend.call( + "admin.verdicts.detail", "/admin/verdicts/{verdict_id}", domain=warehouse + ), ] diff --git a/tests/unit/admin/views/test_verdicts.py b/tests/unit/admin/views/test_verdicts.py new file mode 100644 index 000000000000..7d28820ca9cf --- /dev/null +++ b/tests/unit/admin/views/test_verdicts.py @@ -0,0 +1,63 @@ +# 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 uuid + +from random import randint + +import pretend +import pytest + +from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound + +from warehouse.admin.views import verdicts as views + +from ....common.db.malware import MalwareVerdictFactory + + +class TestListVerdicts: + def test_none(self, db_request): + assert views.get_verdicts(db_request) == {"verdicts": []} + + def test_some(self, db_request): + verdicts = [MalwareVerdictFactory.create() for _ in range(10)] + + assert views.get_verdicts(db_request) == {"verdicts": verdicts} + + def test_some_with_multipage(self, db_request): + verdicts = [MalwareVerdictFactory.create() for _ in range(60)] + + db_request.GET["page"] = "2" + + assert views.get_verdicts(db_request) == {"verdicts": verdicts[25:50]} + + def test_with_invalid_page(self): + request = pretend.stub(params={"page": "not an integer"}) + + with pytest.raises(HTTPBadRequest): + views.get_verdicts(request) + + +class TestGetVerdict: + def test_found(self, db_request): + verdicts = [MalwareVerdictFactory.create() for _ in range(10)] + index = randint(0, 9) + lookup_id = verdicts[index].id + db_request.matchdict["verdict_id"] = lookup_id + + assert views.get_verdict(db_request) == {"verdict": verdicts[index]} + + def test_not_found(self, db_request): + db_request.matchdict["verdict_id"] = uuid.uuid4() + + with pytest.raises(HTTPNotFound): + views.get_verdict(db_request) diff --git a/tests/unit/malware/test_tasks.py b/tests/unit/malware/test_tasks.py index 5c89cc5fd562..234d05dba5bb 100644 --- a/tests/unit/malware/test_tasks.py +++ b/tests/unit/malware/test_tasks.py @@ -266,13 +266,14 @@ def test_no_verdicts(self, db_session): log=pretend.stub(info=pretend.call_recorder(lambda *args, **kwargs: None),), ) task = pretend.stub() - remove_verdicts(task, request, check.name) + removed = remove_verdicts(task, request, check.name) assert request.log.info.calls == [ pretend.call( "Removing 0 malware verdicts associated with %s version 1." % check.name ), ] + assert removed == 0 @pytest.mark.parametrize(("check_with_verdicts"), [True, False]) def test_many_verdicts(self, db_session, check_with_verdicts): @@ -286,6 +287,8 @@ def test_many_verdicts(self, db_session, check_with_verdicts): for i in range(num_verdicts): MalwareVerdictFactory.create(check=check1, release_file=file0) + assert db_session.query(MalwareVerdict).count() == num_verdicts + request = pretend.stub( db=db_session, log=pretend.stub(info=pretend.call_recorder(lambda *args, **kwargs: None),), @@ -299,7 +302,7 @@ def test_many_verdicts(self, db_session, check_with_verdicts): wiped_out_check = check0 num_verdicts = 0 - remove_verdicts(task, request, wiped_out_check.name) + removed = remove_verdicts(task, request, wiped_out_check.name) assert request.log.info.calls == [ pretend.call( @@ -307,3 +310,5 @@ def test_many_verdicts(self, db_session, check_with_verdicts): % (num_verdicts, wiped_out_check.name) ), ] + + assert removed == num_verdicts diff --git a/warehouse/admin/routes.py b/warehouse/admin/routes.py index 4180df8188a6..284932c29190 100644 --- a/warehouse/admin/routes.py +++ b/warehouse/admin/routes.py @@ -139,3 +139,7 @@ def includeme(config): "/admin/checks/{check_name}/change_state", domain=warehouse, ) + config.add_route("admin.verdicts.list", "/admin/verdicts/", domain=warehouse) + config.add_route( + "admin.verdicts.detail", "/admin/verdicts/{verdict_id}", domain=warehouse + ) diff --git a/warehouse/admin/templates/admin/base.html b/warehouse/admin/templates/admin/base.html index cc74b4675b81..fffa7ccfcec5 100644 --- a/warehouse/admin/templates/admin/base.html +++ b/warehouse/admin/templates/admin/base.html @@ -130,6 +130,11 @@ Checks +
  • + + Verdicts + +
  • diff --git a/warehouse/admin/templates/admin/malware/verdicts/detail.html b/warehouse/admin/templates/admin/malware/verdicts/detail.html new file mode 100644 index 000000000000..7702943e8692 --- /dev/null +++ b/warehouse/admin/templates/admin/malware/verdicts/detail.html @@ -0,0 +1,80 @@ +{# + # 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. +-#} +{% extends "admin/base.html" %} + +{% block title %}Verdict {{ verdict.id }}{% endblock %} + +{% block breadcrumb %} +
  • Verdicts
  • +
  • {{ verdict.id }}
  • +{% endblock %} + +{% block content %} +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if verdict.manually_reviewed %} + + + + + {% endif %} + {% if verdict.full_report_link %} + + + + + {% endif %} + {% if verdict.details %} + + + + + {% endif %} +
    Message{{ verdict.message }}
    Run Date{{ verdict.run_date }}
    Check + + {{ verdict.check.name }} v{{ verdict.check.version }} + +
    Object{% include 'object_link.html' %}
    Verdict Classification{{ verdict.classification.value }}
    Verdict Confidence{{ verdict.confidence.value }}
    Manually Reviewed{{ verdict.manually_reviewed }}
    Administrator Verdict{{ verdict.administrator_verdict }}
    Full Report Link{{ verdict.full_report_link }}
    Details
    {{ verdict.details|tojson(indent=4) }}
    +
    +
    +{% endblock %} diff --git a/warehouse/admin/templates/admin/malware/verdicts/index.html b/warehouse/admin/templates/admin/malware/verdicts/index.html new file mode 100644 index 000000000000..d6ab7ef6b028 --- /dev/null +++ b/warehouse/admin/templates/admin/malware/verdicts/index.html @@ -0,0 +1,93 @@ +{# + # 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. +-#} +{% extends "admin/base.html" %} + +{% import "admin/utils/pagination.html" as pagination %} + +{% block title %}Malware Verdicts{% endblock %} + +{% block breadcrumb %} +
  • Verdicts
  • +{% endblock %} + +{% block content %} +
    +
    + + + + + + + + + {% for verdict in verdicts %} + + + + + + + + {% else %} + + + + {% endfor %} +
    ObjectCheckClassificationConfidenceDetail
    {% include 'object_link.html' %} + + {{ verdict.check.name }} v{{ verdict.check.version }} + + + + + {% if verdict.classification.value == 'indeterminate' %} + + {% elif verdict.classification.value == 'threat' %} + + + {% endif %} + + + + + {% if verdict.confidence.value == 'medium' %} + + {% elif verdict.confidence.value == 'high' %} + + + {% endif %} + + + + Detail + +
    +
    + No verdicts! +
    +
    + +
    +
    +{% endblock content %} diff --git a/warehouse/admin/templates/admin/malware/verdicts/object_link.html b/warehouse/admin/templates/admin/malware/verdicts/object_link.html new file mode 100644 index 000000000000..c31678ce419c --- /dev/null +++ b/warehouse/admin/templates/admin/malware/verdicts/object_link.html @@ -0,0 +1,21 @@ +{# + # 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. +-#} + +{% if verdict.project %} + {{ verdict.project.name }} +{% elif verdict.release %} + {{ verdict.release.project.name }}-{{ verdict.release.version }} +{% else %} + {{ verdict.release_file.filename}} +{% endif %} diff --git a/warehouse/admin/views/verdicts.py b/warehouse/admin/views/verdicts.py new file mode 100644 index 000000000000..bd9c2eae68ca --- /dev/null +++ b/warehouse/admin/views/verdicts.py @@ -0,0 +1,61 @@ +# 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 paginate_sqlalchemy import SqlalchemyOrmPage as SQLAlchemyORMPage +from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound +from pyramid.view import view_config + +from warehouse.malware.models import MalwareVerdict +from warehouse.utils.paginate import paginate_url_factory + + +@view_config( + route_name="admin.verdicts.list", + renderer="admin/malware/verdicts/index.html", + permission="moderator", + request_method="GET", + uses_session=True, +) +def get_verdicts(request): + try: + page_num = int(request.params.get("page", 1)) + except ValueError: + raise HTTPBadRequest("'page' must be an integer.") from None + + verdicts_query = request.db.query(MalwareVerdict).order_by( + MalwareVerdict.run_date.desc() + ) + + verdicts = SQLAlchemyORMPage( + verdicts_query, + page=page_num, + items_per_page=25, + url_maker=paginate_url_factory(request), + ) + + return {"verdicts": verdicts} + + +@view_config( + route_name="admin.verdicts.detail", + renderer="admin/malware/verdicts/detail.html", + permission="moderator", + request_method="GET", + uses_session=True, +) +def get_verdict(request): + verdict = request.db.query(MalwareVerdict).get(request.matchdict["verdict_id"]) + + if verdict: + return {"verdict": verdict} + + raise HTTPNotFound diff --git a/warehouse/malware/tasks.py b/warehouse/malware/tasks.py index 0d2f570c436f..a70b7ea344d6 100644 --- a/warehouse/malware/tasks.py +++ b/warehouse/malware/tasks.py @@ -95,7 +95,7 @@ def remove_verdicts(task, request, check_name): .filter(MalwareCheck.name == check_name) .all() ) - + total_deleted = 0 for check_id, check_version in check_ids: query = request.db.query(MalwareVerdict).filter( MalwareVerdict.check_id == check_id @@ -105,4 +105,7 @@ def remove_verdicts(task, request, check_name): "Removing %d malware verdicts associated with %s version %d." % (num_verdicts, check_name, check_version) ) - query.delete(synchronize_session=False) + total_deleted += query.delete(synchronize_session=False) + + # This returned value is only relevant for testing. + return total_deleted