Skip to content

Commit 32ed6ed

Browse files
authored
Add verdict administrator review. Fixes #6062. (#7339)
* Add verdict administrator review. Fixes #6062. - Add new `admin.verdicts.review` endpoint - Change layout of verdict list and detail view and add forms - Change sort order of the MalwareChecks, and update the tests * Code review changes. - Rename MalwareVerdict field `administrator_verdict` to `reviewer_verdict`. - Change verdict review permission from `admin` to `moderator`.
1 parent 31b711c commit 32ed6ed

File tree

11 files changed

+142
-25
lines changed

11 files changed

+142
-25
lines changed

tests/common/db/malware.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ class Meta:
5555
release = None
5656
project = None
5757
manually_reviewed = True
58-
administrator_verdict = factory.fuzzy.FuzzyChoice(list(VerdictClassification))
58+
reviewer_verdict = factory.fuzzy.FuzzyChoice(list(VerdictClassification))
5959
classification = factory.fuzzy.FuzzyChoice(list(VerdictClassification))
6060
confidence = factory.fuzzy.FuzzyChoice(list(VerdictConfidence))
6161
message = factory.fuzzy.FuzzyText(length=80)

tests/unit/admin/test_routes.py

+5
Original file line numberDiff line numberDiff line change
@@ -141,4 +141,9 @@ def test_includeme():
141141
pretend.call(
142142
"admin.verdicts.detail", "/admin/verdicts/{verdict_id}", domain=warehouse
143143
),
144+
pretend.call(
145+
"admin.verdicts.review",
146+
"/admin/verdicts/{verdict_id}/review",
147+
domain=warehouse,
148+
),
144149
]

tests/unit/admin/views/test_checks.py

+8-2
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,21 @@ def test_get_checks_none(self, db_request):
2828

2929
def test_get_checks(self, db_request):
3030
checks = [MalwareCheckFactory.create() for _ in range(10)]
31-
assert views.get_checks(db_request) == {"checks": checks}
31+
result = views.get_checks(db_request)["checks"]
32+
assert len(result) == len(checks)
33+
for r in result:
34+
assert r in checks
3235

3336
def test_get_checks_different_versions(self, db_request):
3437
checks = [MalwareCheckFactory.create() for _ in range(5)]
3538
checks_same = [
3639
MalwareCheckFactory.create(name="MyCheck", version=i) for i in range(1, 6)
3740
]
3841
checks.append(checks_same[-1])
39-
assert views.get_checks(db_request) == {"checks": checks}
42+
result = views.get_checks(db_request)["checks"]
43+
assert len(result) == len(checks)
44+
for r in result:
45+
assert r in checks
4046

4147

4248
class TestGetCheck:

tests/unit/admin/views/test_verdicts.py

+47-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from random import randint
1616

17+
import pretend
1718
import pytest
1819

1920
from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound
@@ -193,10 +194,55 @@ def test_found(self, db_request):
193194
lookup_id = verdicts[index].id
194195
db_request.matchdict["verdict_id"] = lookup_id
195196

196-
assert views.get_verdict(db_request) == {"verdict": verdicts[index]}
197+
assert views.get_verdict(db_request) == {
198+
"verdict": verdicts[index],
199+
"classifications": ["Benign", "Indeterminate", "Threat"],
200+
}
197201

198202
def test_not_found(self, db_request):
199203
db_request.matchdict["verdict_id"] = uuid.uuid4()
200204

201205
with pytest.raises(HTTPNotFound):
202206
views.get_verdict(db_request)
207+
208+
209+
class TestReviewVerdict:
210+
@pytest.mark.parametrize(
211+
"manually_reviewed, reviewer_verdict",
212+
[
213+
(False, None), # unreviewed verdict
214+
(True, VerdictClassification.Threat), # previously reviewed
215+
],
216+
)
217+
def test_set_classification(self, db_request, manually_reviewed, reviewer_verdict):
218+
verdict = MalwareVerdictFactory.create(
219+
manually_reviewed=manually_reviewed, reviewer_verdict=reviewer_verdict,
220+
)
221+
222+
db_request.matchdict["verdict_id"] = verdict.id
223+
db_request.POST = {"classification": "Benign"}
224+
db_request.session = pretend.stub(
225+
flash=pretend.call_recorder(lambda *a, **kw: None)
226+
)
227+
228+
db_request.route_path = pretend.call_recorder(
229+
lambda *a, **kw: "/admin/verdicts/%s/review" % verdict.id
230+
)
231+
232+
views.review_verdict(db_request)
233+
234+
assert db_request.session.flash.calls == [
235+
pretend.call("Verdict %s marked as reviewed." % verdict.id, queue="success")
236+
]
237+
238+
assert verdict.manually_reviewed
239+
assert verdict.reviewer_verdict == VerdictClassification.Benign
240+
241+
@pytest.mark.parametrize("post_params", [{}, {"classification": "Nope"}])
242+
def test_errors(self, db_request, post_params):
243+
verdict = MalwareVerdictFactory.create()
244+
db_request.matchdict["verdict_id"] = verdict.id
245+
db_request.POST = post_params
246+
247+
with pytest.raises(HTTPBadRequest):
248+
views.review_verdict(db_request)

warehouse/admin/routes.py

+3
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,6 @@ def includeme(config):
148148
config.add_route(
149149
"admin.verdicts.detail", "/admin/verdicts/{verdict_id}", domain=warehouse
150150
)
151+
config.add_route(
152+
"admin.verdicts.review", "/admin/verdicts/{verdict_id}/review", domain=warehouse
153+
)

warehouse/admin/templates/admin/malware/verdicts/detail.html

+25-14
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
-#}
1414
{% extends "admin/base.html" %}
1515

16-
{% block title %}Verdict {{ verdict.id }}{% endblock %}
16+
{% block title %}Verdict Details{% endblock %}
1717

1818
{% block breadcrumb %}
1919
<li><a href="{{ request.route_path('admin.verdicts.list') }}">Verdicts</a></li>
@@ -44,24 +44,20 @@
4444
<th scope="row">Object</th>
4545
<td>{% include 'object_link.html' %}</td>
4646
</tr>
47+
{% if verdict.manually_reviewed %}
4748
<tr>
48-
<th scope="row">Verdict Classification</th>
49-
<td>{{ verdict.classification.value }}</td>
50-
</tr>
51-
<tr>
52-
<th scope="row">Verdict Confidence</th>
53-
<td>{{ verdict.confidence.value }}</td>
49+
<th scope="row">Reviewer Verdict</th>
50+
<td>{{ verdict.reviewer_verdict.value }}</td>
5451
</tr>
52+
{% endif %}
5553
<tr>
56-
<th scope="row">Manually Reviewed</th>
57-
<td>{{ verdict.manually_reviewed }}</td>
54+
<th scope="row">Check Verdict</th>
55+
<td>{{ verdict.classification.value }}</td>
5856
</tr>
59-
{% if verdict.manually_reviewed %}
6057
<tr>
61-
<th scope="row">Administrator Verdict</th>
62-
<td>{{ verdict.administrator_verdict }}</td>
58+
<th scope="row">Confidence</th>
59+
<td>{{ verdict.confidence.value }}</td>
6360
</tr>
64-
{% endif %}
6561
{% if verdict.full_report_link %}
6662
<tr>
6763
<th scope="row">Full Report Link</th>
@@ -70,10 +66,25 @@
7066
{% endif %}
7167
{% if verdict.details %}
7268
<tr>
73-
<th scope="row">Details</th>
69+
<th scope="row">Additional Details</th>
7470
<td><pre>{{ verdict.details|tojson(indent=4) }}</pre></td>
7571
</tr>
7672
{% endif %}
73+
<tr>
74+
<th scope="row">{{ "Change" if verdict.manually_reviewed else "Set"}} Verdict</th>
75+
<td>
76+
<form class="form-inline" method="POST" action="{{ request.route_path('admin.verdicts.review', verdict_id=verdict.id) }}">
77+
<input name="csrf_token" type="hidden" value="{{ request.session.get_csrf_token() }}">
78+
<select class="form-control" name="classification">
79+
<option disabled selected></option>
80+
{% for c in classifications %}
81+
<option>{{ c }}</option>
82+
{% endfor %}
83+
</select>
84+
<button type="submit" class="btn btn-primary">Save</button>
85+
</form>
86+
</td>
87+
</tr>
7788
</table>
7889
</div>
7990
</div>

warehouse/admin/templates/admin/malware/verdicts/index.html

+15-3
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
<div class="box-body table-responsive no-padding">
6868
<table class="table table-hover">
6969
<tr>
70+
<th>Investigate</th>
7071
<th>Object</th>
7172
<th>Check</th>
7273
<th>Classification</th>
@@ -75,6 +76,11 @@
7576
</tr>
7677
{% for verdict in verdicts %}
7778
<tr>
79+
<td>
80+
<a href="{{ request.route_path('admin.verdicts.detail', verdict_id=verdict.id) }}">
81+
Detail
82+
</a>
83+
</td>
7884
<td>{% include 'object_link.html' %}</td>
7985
<td>
8086
<a href="{{ request.route_path('admin.checks.detail', check_name=verdict.check.name) }}">
@@ -104,9 +110,15 @@
104110
</span>
105111
</td>
106112
<td>
107-
<a href="{{ request.route_path('admin.verdicts.detail', verdict_id=verdict.id) }}">
108-
Detail
109-
</a>
113+
{% if verdict.manually_reviewed %}
114+
Marked as {{ verdict.reviewer_verdict.value }}
115+
{% else %}
116+
<form method="POST" action="{{ request.route_path('admin.verdicts.review', verdict_id=verdict.id, _query=request.params) }}">
117+
<input type="hidden" name="classification" value="Benign">
118+
<input name="csrf_token" type="hidden" value="{{ request.session.get_csrf_token() }}">
119+
<input type="submit" value="Mark Benign">
120+
</form>
121+
{% endif %}
110122
</td>
111123
</tr>
112124
{% else %}

warehouse/admin/views/checks.py

+2
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ def get_checks(request):
3434
if not check.is_stale:
3535
active_checks.append(check)
3636

37+
active_checks.sort(key=lambda check: check.created, reverse=True)
38+
3739
return {"checks": active_checks}
3840

3941

warehouse/admin/views/verdicts.py

+34-2
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
# limitations under the License.
1212

1313
from paginate_sqlalchemy import SqlalchemyOrmPage as SQLAlchemyORMPage
14-
from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound
14+
from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound, HTTPSeeOther
1515
from pyramid.view import view_config
1616

1717
from warehouse.malware.models import (
@@ -61,11 +61,43 @@ def get_verdict(request):
6161
verdict = request.db.query(MalwareVerdict).get(request.matchdict["verdict_id"])
6262

6363
if verdict:
64-
return {"verdict": verdict}
64+
return {
65+
"verdict": verdict,
66+
"classifications": list(VerdictClassification.__members__.keys()),
67+
}
6568

6669
raise HTTPNotFound
6770

6871

72+
@view_config(
73+
route_name="admin.verdicts.review",
74+
permission="moderator",
75+
request_method="POST",
76+
uses_session=True,
77+
require_methods=False,
78+
require_csrf=True,
79+
)
80+
def review_verdict(request):
81+
verdict = request.db.query(MalwareVerdict).get(request.matchdict["verdict_id"])
82+
83+
try:
84+
classification = getattr(VerdictClassification, request.POST["classification"])
85+
except (KeyError, AttributeError):
86+
raise HTTPBadRequest("Invalid verdict classification.") from None
87+
88+
verdict.manually_reviewed = True
89+
verdict.reviewer_verdict = classification
90+
91+
request.session.flash(
92+
"Verdict %s marked as reviewed." % verdict.id, queue="success"
93+
)
94+
95+
# If no query params are provided (e.g. request originating from
96+
# admins.verdicts.detail view), then route to the default list view
97+
query = request.GET or {"classification": "threat", "manually_reviewed": "0"}
98+
return HTTPSeeOther(request.route_path("admin.verdicts.list", _query=query))
99+
100+
69101
def validate_fields(request, validators):
70102
try:
71103
int(request.params.get("page", 1))

warehouse/malware/models.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ class MalwareVerdict(db.Model):
161161
message = Column(Text, nullable=True)
162162
details = Column(JSONB, nullable=True)
163163
manually_reviewed = Column(Boolean, nullable=False, server_default=sql.false())
164-
administrator_verdict = Column(
164+
reviewer_verdict = Column(
165165
Enum(VerdictClassification, values_callable=lambda x: [e.value for e in x]),
166166
nullable=True,
167167
)

warehouse/migrations/versions/061ff3d24c22_add_malware_detection_tables.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ def upgrade():
9191
server_default=sa.text("false"),
9292
nullable=False,
9393
),
94-
sa.Column("administrator_verdict", VerdictClassifications, nullable=True,),
94+
sa.Column("reviewer_verdict", VerdictClassifications, nullable=True,),
9595
sa.Column("full_report_link", sa.String(), nullable=True),
9696
sa.ForeignKeyConstraint(
9797
["check_id"], ["malware_checks.id"], onupdate="CASCADE", ondelete="CASCADE"

0 commit comments

Comments
 (0)