Skip to content

Commit

Permalink
feat(admin): Add a Malware-specific UI (#15687)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* feat(dev): add some more color to generated summary

Signed-off-by: Mike Fiedler <[email protected]>

* feat(admin): show malware counts on dashboard

Signed-off-by: Mike Fiedler <[email protected]>

* feat(admin): malware reports list and details

Signed-off-by: Mike Fiedler <[email protected]>

* refactor: move csp directives to admin-only

Signed-off-by: Mike Fiedler <[email protected]>

---------

Signed-off-by: Mike Fiedler <[email protected]>
  • Loading branch information
miketheman authored Mar 29, 2024
1 parent 7f47a84 commit d1a074a
Show file tree
Hide file tree
Showing 16 changed files with 444 additions and 33 deletions.
4 changes: 4 additions & 0 deletions tests/common/db/packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@

from .accounts import UserFactory
from .base import WarehouseFactory
from .observations import ObserverFactory

fake = faker.Faker()

Expand All @@ -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]
)
Expand Down
10 changes: 10 additions & 0 deletions tests/unit/admin/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
26 changes: 24 additions & 2 deletions tests/unit/admin/views/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
]
38 changes: 38 additions & 0 deletions tests/unit/admin/views/test_malware_reports.py
Original file line number Diff line number Diff line change
@@ -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}
28 changes: 28 additions & 0 deletions tests/unit/test_csp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
10 changes: 10 additions & 0 deletions warehouse/admin/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions warehouse/admin/static/css/admin.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
89 changes: 62 additions & 27 deletions warehouse/admin/static/js/warehouse.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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()));
}
9 changes: 7 additions & 2 deletions warehouse/admin/templates/admin/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
# limitations under the License.
-#}
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
Expand Down Expand Up @@ -113,6 +113,11 @@
<ul class="nav nav-pills nav-sidebar flex-column" data-widget="treeview" role="menu" data-accordion="false">
{# TODO: Is there a cleaner way to show/hide the allowed sections? #}
{% if request.has_permission(Permissions.AdminDashboardSidebarRead) %}
<li class="nav-item">
<a href="{{ request.route_path('admin.malware_reports.list') }}" class="nav-link">
<i class="fa-solid fa-dumpster-fire nav-icon"></i> <p>Malware Reports <span class="right badge badge-danger">New</span></p>
</a>
</li>
<li class="nav-item">
<a href="{{ request.route_path('admin.organization_application.list') }}" class="nav-link">
<i class="fa fa-sitemap nav-icon"></i> <p>Organization Applications</p>
Expand All @@ -130,7 +135,7 @@
</li>
<li class="nav-item">
<a href="{{ request.route_path('admin.macaroon.decode_token') }}" class="nav-link">
<i class="fa-solid fa-stroopwafel nav-icon"></i> <p>Macaroons <span class="right badge badge-danger">New</span></p>
<i class="fa-solid fa-stroopwafel nav-icon"></i> <p>Macaroons</p>
</a>
</li>
<li class="nav-item">
Expand Down
24 changes: 24 additions & 0 deletions warehouse/admin/templates/admin/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,28 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-#}

{% extends "base.html" %}

{% block content %}
<div class="row">
{% if malware_reports_count %}
<div class="col-lg-4 col-6">
<div class="small-box bg-gradient-warning">
<div class="inner">
<h3>{{ malware_reports_count }}</h3>
<p>Open Malware Reports</p>
</div>
<div class="icon">
<i class="fa-solid fa-dumpster-fire"></i>
</div>
<a href="{{ request.route_path("admin.malware_reports.list") }}"
class="small-box-footer">
More info <i class="fas fa-arrow-circle-right"></i>
</a>
</div>
</div>
{% endif %}
</div>
<!-- /.row -->
{% endblock %}
Loading

0 comments on commit d1a074a

Please sign in to comment.