From d1a074a100022b576fc4f8c202128ced2c2b80f9 Mon Sep 17 00:00:00 2001 From: Mike Fiedler Date: Fri, 29 Mar 2024 12:41:39 -0400 Subject: [PATCH] feat(admin): Add a Malware-specific UI (#15687) * fix(admin): activate datatables only when found If a table wasn't found, the rest of the module's execution would halt. Signed-off-by: Mike Fiedler * feat(dev): add some more color to generated summary Signed-off-by: Mike Fiedler * feat(admin): show malware counts on dashboard Signed-off-by: Mike Fiedler * feat(admin): malware reports list and details Signed-off-by: Mike Fiedler * refactor: move csp directives to admin-only Signed-off-by: Mike Fiedler --------- Signed-off-by: Mike Fiedler --- tests/common/db/packaging.py | 4 + tests/unit/admin/test_routes.py | 10 +++ tests/unit/admin/views/test_core.py | 26 +++++- .../unit/admin/views/test_malware_reports.py | 38 ++++++++ tests/unit/test_csp.py | 28 ++++++ warehouse/admin/routes.py | 10 +++ warehouse/admin/static/css/admin.scss | 1 + warehouse/admin/static/js/warehouse.js | 89 +++++++++++++------ warehouse/admin/templates/admin/base.html | 9 +- .../admin/templates/admin/dashboard.html | 24 +++++ .../admin/malware_reports/detail.html | 79 ++++++++++++++++ .../templates/admin/malware_reports/list.html | 71 +++++++++++++++ warehouse/admin/views/core.py | 14 ++- warehouse/admin/views/malware_reports.py | 64 +++++++++++++ warehouse/cli/observations.py | 4 +- warehouse/csp.py | 6 ++ 16 files changed, 444 insertions(+), 33 deletions(-) create mode 100644 tests/unit/admin/views/test_malware_reports.py create mode 100644 warehouse/admin/templates/admin/malware_reports/detail.html create mode 100644 warehouse/admin/templates/admin/malware_reports/list.html create mode 100644 warehouse/admin/views/malware_reports.py diff --git a/tests/common/db/packaging.py b/tests/common/db/packaging.py index 462f6bb9e0a9..f6aba2f8c4ba 100644 --- a/tests/common/db/packaging.py +++ b/tests/common/db/packaging.py @@ -35,6 +35,7 @@ from .accounts import UserFactory from .base import WarehouseFactory +from .observations import ObserverFactory fake = faker.Faker() @@ -61,6 +62,9 @@ class ProjectObservationFactory(WarehouseFactory): class Meta: model = Project.Observation + related = factory.SubFactory(ProjectFactory) + observer = factory.SubFactory(ObserverFactory) + kind = factory.Faker( "random_element", elements=[kind.value[1] for kind in ObservationKind] ) diff --git a/tests/unit/admin/test_routes.py b/tests/unit/admin/test_routes.py index 09cba574612f..c8f3d38c7c55 100644 --- a/tests/unit/admin/test_routes.py +++ b/tests/unit/admin/test_routes.py @@ -256,6 +256,16 @@ def test_includeme(): "/admin/observations/", domain=warehouse, ), + pretend.call( + "admin.malware_reports.list", + "/admin/malware_reports/", + domain=warehouse, + ), + pretend.call( + "admin.malware_reports.detail", + "/admin/malware_reports/{observation_id}/", + domain=warehouse, + ), pretend.call("admin.emails.list", "/admin/emails/", domain=warehouse), pretend.call("admin.emails.mass", "/admin/emails/mass/", domain=warehouse), pretend.call( diff --git a/tests/unit/admin/views/test_core.py b/tests/unit/admin/views/test_core.py index 69f0d2390f79..01a1f20b44f7 100644 --- a/tests/unit/admin/views/test_core.py +++ b/tests/unit/admin/views/test_core.py @@ -14,7 +14,29 @@ from warehouse.admin.views import core as views +from ....common.db.packaging import ProjectObservationFactory + class TestDashboard: - def test_dashboard(self): - assert views.dashboard(pretend.stub()) == {} + def test_dashboard(self, pyramid_request): + pyramid_request.has_permission = pretend.call_recorder(lambda perm: False) + + assert views.dashboard(pyramid_request) == { + "malware_reports_count": None, + } + + assert pyramid_request.has_permission.calls == [ + pretend.call(views.Permissions.AdminObservationsRead), + ] + + def test_dashboard_with_permission_and_observation(self, db_request): + ProjectObservationFactory.create(kind="is_malware") + db_request.user = pretend.stub() + db_request.has_permission = pretend.call_recorder(lambda perm: True) + + assert views.dashboard(db_request) == { + "malware_reports_count": 1, + } + assert db_request.has_permission.calls == [ + pretend.call(views.Permissions.AdminObservationsRead), + ] diff --git a/tests/unit/admin/views/test_malware_reports.py b/tests/unit/admin/views/test_malware_reports.py new file mode 100644 index 000000000000..5ee4d9d71cdb --- /dev/null +++ b/tests/unit/admin/views/test_malware_reports.py @@ -0,0 +1,38 @@ +# 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 warehouse.admin.views import malware_reports as views + +from ....common.db.packaging import ProjectObservationFactory + + +class TestMalwareReportsList: + def test_malware_reports_list(self, db_request): + assert views.malware_reports_list(db_request) == {"malware_reports": []} + + def test_malware_reports_list_with_observations(self, db_request): + ProjectObservationFactory.create(kind="is_spam") + malware = ProjectObservationFactory.create_batch(size=3, kind="is_malware") + + assert views.malware_reports_list(db_request) == {"malware_reports": malware} + + +class TestMalwareReportsDetail: + def test_malware_reports_detail(self, db_request): + assert views.malware_reports_detail(db_request) == {"report": None} + + def test_malware_reports_detail_with_report(self, db_request): + report = ProjectObservationFactory.create(kind="is_malware") + db_request.matchdict["observation_id"] = str(report.id) + + assert views.malware_reports_detail(db_request) == {"report": report} diff --git a/tests/unit/test_csp.py b/tests/unit/test_csp.py index 39abe3daec3e..eaeb7a1a611a 100644 --- a/tests/unit/test_csp.py +++ b/tests/unit/test_csp.py @@ -159,6 +159,34 @@ def test_simple_csp(self): ) } + def test_admin_csp(self): + settings = { + "csp": { + "default-src": ["'none'"], + "frame-src": ["'none'"], + "img-src": ["'self'"], + } + } + response = pretend.stub(headers={}) + registry = pretend.stub(settings=settings) + handler = pretend.call_recorder(lambda request: response) + + tween = csp.content_security_policy_tween_factory(handler, registry) + + request = pretend.stub( + path="/admin/", + find_service=pretend.call_recorder(lambda *args, **kwargs: settings["csp"]), + ) + + assert tween(request) is response + assert response.headers == { + "Content-Security-Policy": ( + "default-src 'none'; " + "frame-src https://inspector.pypi.io; " + "img-src 'self' data:" + ) + } + class TestCSPPolicy: def test_create(self): diff --git a/warehouse/admin/routes.py b/warehouse/admin/routes.py index abcce14cdfc2..eefb57f4cd5f 100644 --- a/warehouse/admin/routes.py +++ b/warehouse/admin/routes.py @@ -264,6 +264,16 @@ def includeme(config): config.add_route( "admin.observations.list", "/admin/observations/", domain=warehouse ) + config.add_route( + "admin.malware_reports.list", + "/admin/malware_reports/", + domain=warehouse, + ) + config.add_route( + "admin.malware_reports.detail", + "/admin/malware_reports/{observation_id}/", + domain=warehouse, + ) # Email related Admin pages config.add_route("admin.emails.list", "/admin/emails/", domain=warehouse) diff --git a/warehouse/admin/static/css/admin.scss b/warehouse/admin/static/css/admin.scss index 7fe537300273..130393420747 100644 --- a/warehouse/admin/static/css/admin.scss +++ b/warehouse/admin/static/css/admin.scss @@ -18,6 +18,7 @@ @import "admin-lte/plugins/datatables-bs4/css/dataTables.bootstrap4.css"; @import "admin-lte/plugins/datatables-responsive/css/responsive.bootstrap4.css"; @import "admin-lte/plugins/datatables-buttons/css/buttons.bootstrap4.css"; +@import "admin-lte/plugins/datatables-rowgroup/css/rowGroup.bootstrap4.css"; @import "admin-lte/dist/css/adminlte.css"; diff --git a/warehouse/admin/static/js/warehouse.js b/warehouse/admin/static/js/warehouse.js index 090a694bb313..6aab66e56ed1 100644 --- a/warehouse/admin/static/js/warehouse.js +++ b/warehouse/admin/static/js/warehouse.js @@ -23,6 +23,8 @@ import "admin-lte/plugins/datatables-buttons/js/dataTables.buttons"; import "admin-lte/plugins/datatables-buttons/js/buttons.bootstrap4"; import "admin-lte/plugins/datatables-buttons/js/buttons.html5"; import "admin-lte/plugins/datatables-buttons/js/buttons.colVis"; +import "admin-lte/plugins/datatables-rowgroup/js/dataTables.rowGroup"; +import "admin-lte/plugins/datatables-rowgroup/js/rowGroup.bootstrap4"; // Import AdminLTE JS import "admin-lte/build/js/AdminLTE"; @@ -111,35 +113,68 @@ document.querySelectorAll(".copy-text").forEach(function (element) { }); // Activate Datatables https://datatables.net/ +// Guard each one to not break execution if the table isn't present + // User Account Activity -let table = $("#account-activity").DataTable({ - responsive: true, - lengthChange: false, -}); -// sort by time -table.column(".time").order("desc").draw(); -// Hide some columns we don't need to see all the time -table.columns([".ip_address", ".hashed_ip"]).visible(false); -// add column visibility button -new $.fn.dataTable.Buttons(table, {buttons: ["copy", "csv", "colvis"]}); -table.buttons().container().appendTo($(".col-md-6:eq(0)", table.table().container())); +let accountActivityTable = $("#account-activity") +if (accountActivityTable.length) { + let table = accountActivityTable.DataTable({ + responsive: true, + lengthChange: false, + }); + // sort by time + table.column(".time").order("desc").draw(); + // Hide some columns we don't need to see all the time + table.columns([".ip_address", ".hashed_ip"]).visible(false); + // add column visibility button + new $.fn.dataTable.Buttons(table, {buttons: ["copy", "csv", "colvis"]}); + table.buttons().container().appendTo($(".col-md-6:eq(0)", table.table().container())); +} // User API Tokens -let token_table = $("#api-tokens").DataTable({ - responsive: true, - lengthChange: false, -}); -token_table.columns([".last_used", ".created"]).order([1, "desc"]).draw(); -token_table.columns([".permissions_caveat"]).visible(false); -new $.fn.dataTable.Buttons(token_table, {buttons: ["colvis"]}); -token_table.buttons().container().appendTo($(".col-md-6:eq(0)", token_table.table().container())); +let tokenTable = $("#api-tokens") +if (tokenTable.length) { + let table = tokenTable.DataTable({ + responsive: true, + lengthChange: false, + }); + table.columns([".last_used", ".created"]).order([1, "desc"]).draw(); + table.columns([".permissions_caveat"]).visible(false); + new $.fn.dataTable.Buttons(table, {buttons: ["colvis"]}); + table.buttons().container().appendTo($(".col-md-6:eq(0)", table.table().container())); +} // Observations -let obs_table = $("#observations").DataTable({ - responsive: true, - lengthChange: false, -}); -obs_table.column(".time").order("desc").draw(); -obs_table.columns([".payload"]).visible(false); -new $.fn.dataTable.Buttons(obs_table, {buttons: ["copy", "csv", "colvis"]}); -obs_table.buttons().container().appendTo($(".col-md-6:eq(0)", obs_table.table().container())); +let observationsTable = $("#observations") +if (observationsTable.length) { + let table = observationsTable.DataTable({ + responsive: true, + lengthChange: false, + }); + table.column(".time").order("desc").draw(); + table.columns([".payload"]).visible(false); + new $.fn.dataTable.Buttons(table, {buttons: ["copy", "csv", "colvis"]}); + table.buttons().container().appendTo($(".col-md-6:eq(0)", table.table().container())); +} + +// Malware Reports +let malwareReportsTable = $("#malware-reports") +if (malwareReportsTable.length) { + let table = malwareReportsTable.DataTable({ + displayLength: 25, + lengthChange: false, + order: [[0, "asc"], [2, "desc"]], // alpha name, recent date + responsive: true, + rowGroup: { + dataSrc: 0, + // display row count in group header + startRender: function (rows, group) { + return group + ' (' + rows.count() + ')'; + }, + }, + }); + // hide the project name, since it's in the group title + table.columns([0]).visible(false); + new $.fn.dataTable.Buttons(table, {buttons: ["copy", "csv", "colvis"]}); + table.buttons().container().appendTo($(".col-md-6:eq(0)", table.table().container())); +} diff --git a/warehouse/admin/templates/admin/base.html b/warehouse/admin/templates/admin/base.html index 92d91a22694c..250244c68fd2 100644 --- a/warehouse/admin/templates/admin/base.html +++ b/warehouse/admin/templates/admin/base.html @@ -12,7 +12,7 @@ # limitations under the License. -#} - + @@ -113,6 +113,11 @@