Skip to content

Commit 899a0c1

Browse files
committed
Change, withdraw or resubmit your own exception, and find one by id
The person who asked for an exception can now change its reason, expiry, ticket or kind from the finding page, and withdraw it while it still waits. A rejected or withdrawn one shows the approver's note and an Update and resubmit button, which sends it back for approval with the old decision cleared. An approved one cannot be changed on the screen; an administrator revokes it. Nobody else can change or withdraw a request that is not theirs, administrators included: it answers 404. The Exceptions page shows each exception's id (E77), who approved or rejected it and their note, and Change or Update and resubmit and Withdraw buttons on your own rows. Its search takes an exception id: 77 matches the exception or the finding, E77 or "exception 77" only the exception, #77 only the finding. In the token API, GET /exceptions takes id, one exception number or up to 500 separated by commas, and vuln_id, the finding's GCR id. PATCH on a rejected or withdrawn exception now resubmits it instead of answering 409. The API reference, OpenAPI and Postman files, the Python and bash samples and the changelog cover it.
1 parent c2e6068 commit 899a0c1

9 files changed

Lines changed: 273 additions & 27 deletions

File tree

‎app/core/api_reference.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,11 @@ def endpoints() -> list:
234234
choices=["requested", "approved", "rejected",
235235
"revoked", "expired"]),
236236
_p("finding", "Only the exception on this finding number.",
237-
kind="integer")] + PAGING,
237+
kind="integer"),
238+
_p("id", "Only these exception numbers. One, or up to 500 "
239+
"separated by commas, like 77,81."),
240+
_p("vuln_id", "Only exceptions on the finding with this GCR "
241+
"id, like GCR-1A2B3C4D5E6F.")] + PAGING,
238242
"example": {"total": 1, "limit": 100, "offset": 0, "count": 1, "exceptions": [{
239243
"id": 77, "finding": 4182, "status": "requested", "category": "",
240244
"reason": "Only reachable from the admin network, fix planned in Q1.",
@@ -303,8 +307,8 @@ def endpoints() -> list:
303307
"waiting one stays waiting. An approved one goes "
304308
"back to waiting and its finding returns to the "
305309
"report files until an administrator approves it "
306-
"again. Answers 409 on one that is rejected or "
307-
"revoked: ask again instead.",
310+
"again. A rejected or withdrawn one goes back to "
311+
"waiting, the same as asking again.",
308312
"params": [_p("exception_id", "The exception number.", where="path",
309313
kind="integer", example=77)],
310314
"body": [

‎app/web/routes_api.py‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,120 @@ async def revoke_waiver(waiver_id: int, request: Request,
669669
+ "&kind=warn")
670670

671671

672+
def _own_unapproved_waiver(db, user, waiver_id, allowed):
673+
"""(waiver, finding) for a request this person made, in one of these states.
674+
675+
Somebody else's answers 404, so an id says nothing about whether it
676+
exists. Approved ones are never in the list: what was signed off stays as
677+
it was until an administrator revokes it.
678+
"""
679+
waiver = db.query(Waiver).filter(Waiver.id == waiver_id).one_or_none()
680+
if waiver is None or ((waiver.requested_by or "").lower()
681+
!= (user.username or "").lower()):
682+
raise HTTPException(status_code=404,
683+
detail="No exception request of yours with that id.")
684+
finding = rbac.visible_finding(db, user, waiver.finding_id)
685+
if finding is None:
686+
raise HTTPException(status_code=404,
687+
detail="No exception request of yours with that id.")
688+
if waiver.status not in allowed:
689+
raise HTTPException(
690+
status_code=409,
691+
detail=("That cannot be done to an exception that is "
692+
+ waiver.status.value + "."
693+
+ (" An administrator can revoke it."
694+
if waiver.status == WaiverStatus.APPROVED else "")))
695+
return waiver, finding
696+
697+
698+
@router.post("/waivers/{waiver_id}/edit")
699+
async def edit_own_waiver(waiver_id: int, request: Request,
700+
user: User = Depends(current_user)):
701+
"""Change your own request: reason, expiry, ticket, kind.
702+
703+
Anything not approved. One that was rejected or withdrawn goes back to
704+
waiting, the same as asking again, with the old decision cleared.
705+
"""
706+
await check_csrf(request)
707+
rbac.require(user, "waiver.request")
708+
709+
from urllib.parse import quote
710+
711+
form = await request.form()
712+
reason_text = clean_text(form.get("reason", ""), "Reason", limit=2000)
713+
if len(reason_text) < 20:
714+
raise HTTPException(
715+
status_code=400,
716+
detail="Say a bit more about why this is acceptable, at least 20 characters.")
717+
ticket_text = clean_text(form.get("ticket", ""), "Ticket", limit=120,
718+
required=False)
719+
expires_at = _parse_expiry(str(form.get("expires", "")))
720+
category_value = _waiver_category(form.get("category", ""))
721+
722+
with session_scope() as db:
723+
waiver, finding = _own_unapproved_waiver(
724+
db, user, waiver_id,
725+
{WaiverStatus.REQUESTED, WaiverStatus.REJECTED, WaiverStatus.REVOKED})
726+
reopened = waiver.status != WaiverStatus.REQUESTED
727+
if category_value == "not_deployed" and finding.kind == FindingKind.CREDENTIAL:
728+
raise HTTPException(status_code=400, detail=(
729+
"A credential works wherever it was pushed, deployed or not. "
730+
"Rotate it, or ask for an accepted risk exception instead."))
731+
changed = [name for name, old, new in (
732+
("reason", waiver.reason or "", reason_text),
733+
("ticket", waiver.ticket or "", ticket_text),
734+
("expires", waiver.expires_at, expires_at),
735+
("category", waiver.category or "", category_value)) if old != new]
736+
waiver.reason = reason_text
737+
waiver.ticket = ticket_text
738+
waiver.expires_at = expires_at
739+
waiver.category = category_value
740+
waiver.requested_at = utcnow()
741+
if reopened:
742+
waiver.status = WaiverStatus.REQUESTED
743+
waiver.decided_by = ""
744+
waiver.decided_at = None
745+
waiver.decision_note = ""
746+
db.flush()
747+
record(db, "waiver.edited", actor=user.username,
748+
target=finding.fingerprint,
749+
detail=((", ".join(changed) or "nothing") + " changed"
750+
+ ("; sent for approval again" if reopened else "")),
751+
source_ip=client_ip(request))
752+
where = _back_to(form, finding.id, "/waivers")
753+
754+
return redirect(f"{where}?msg="
755+
+ quote("Request changed. It is still waiting for an administrator.")
756+
+ "&kind=ok")
757+
758+
759+
@router.post("/waivers/{waiver_id}/withdraw")
760+
async def withdraw_own_waiver(waiver_id: int, request: Request,
761+
user: User = Depends(current_user)):
762+
"""Take back your own request before anybody decides on it.
763+
764+
Kept as revoked with a note, like the API's withdraw, so the finding can
765+
be asked about again and the record of the request stays readable.
766+
"""
767+
await check_csrf(request)
768+
rbac.require(user, "waiver.request")
769+
770+
from urllib.parse import quote
771+
772+
form = await request.form()
773+
with session_scope() as db:
774+
waiver, finding = _own_unapproved_waiver(db, user, waiver_id,
775+
{WaiverStatus.REQUESTED})
776+
take_back_waiver(db, waiver, finding, user.username,
777+
note="Withdrawn by the person who asked for it.")
778+
db.flush()
779+
record(db, "waiver.withdrawn", actor=user.username,
780+
target=finding.fingerprint, source_ip=client_ip(request))
781+
where = _back_to(form, finding.id, "/waivers")
782+
783+
return redirect(f"{where}?msg=" + quote("Request withdrawn.") + "&kind=info")
784+
785+
672786
def form_ids(form, *names) -> list:
673787
"""Whole numbers out of a form, under any of these field names.
674788

‎app/web/routes_api_v1.py‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,23 @@ def list_exceptions(request: Request,
633633
raise HTTPException(status_code=400,
634634
detail="finding is a finding number.")
635635
base = base.filter(Waiver.finding_id == int(finding_raw))
636+
id_raw = (params.get("id", "") or "").strip()
637+
if id_raw:
638+
ids = [part.strip() for part in id_raw.split(",") if part.strip()]
639+
if not ids or len(ids) > 500 or not all(i.isdigit() for i in ids):
640+
raise HTTPException(
641+
status_code=400,
642+
detail="id is an exception number, or up to 500 of them "
643+
"separated by commas.")
644+
base = base.filter(Waiver.id.in_({int(i) for i in ids}))
645+
vuln_raw = (params.get("vuln_id", "") or "").strip().upper()
646+
if vuln_raw:
647+
if len(vuln_raw) > 64:
648+
raise HTTPException(status_code=400,
649+
detail="vuln_id is a GCR id, like GCR-1A2B3C4D5E6F.")
650+
base = base.filter(Waiver.finding_id.in_(
651+
shown.filter(Finding.vuln_id == vuln_raw)
652+
.with_entities(Finding.id).scalar_subquery()))
636653

637654
total = base.count()
638655
rows = (base.order_by(Waiver.requested_at.desc(), Waiver.id.desc())
@@ -777,7 +794,8 @@ async def edit_exception(exception_id: int, request: Request,
777794
approver cannot rewrite somebody else's argument under their name. An
778795
approved exception that is changed goes back to waiting: what was signed
779796
off is not what it says any more, so its finding comes back into the
780-
report files until an administrator agrees again.
797+
report files until an administrator agrees again. A rejected or withdrawn
798+
one goes back to waiting too, the same as asking again.
781799
"""
782800
from app.core.audit import record
783801
from app.core.models import FindingKind, WaiverStatus, utcnow
@@ -806,11 +824,11 @@ async def edit_exception(exception_id: int, request: Request,
806824
if not _is_owner(me, waiver):
807825
raise HTTPException(status_code=403,
808826
detail="Only the person who asked for it can change it.")
809-
if waiver.status not in {WaiverStatus.REQUESTED, WaiverStatus.APPROVED}:
810-
raise HTTPException(
811-
status_code=409,
812-
detail="That one is closed. Ask again with POST "
813-
"/findings/{id}/exceptions.")
827+
if waiver.status not in {WaiverStatus.REQUESTED, WaiverStatus.APPROVED,
828+
WaiverStatus.REJECTED, WaiverStatus.REVOKED}:
829+
raise HTTPException(status_code=409,
830+
detail="That one cannot be changed.")
831+
reopened = waiver.status in {WaiverStatus.REJECTED, WaiverStatus.REVOKED}
814832

815833
if "reason" in body:
816834
reason = clean_text(body.get("reason") or "", "Reason", limit=2000)
@@ -846,6 +864,7 @@ async def edit_exception(exception_id: int, request: Request,
846864
record(db, "waiver.edited", actor=me.username, target=finding.fingerprint,
847865
detail=(f"{', '.join(sorted(body))} changed"
848866
+ ("; approval undone, waiting again" if was_approved else "")
867+
+ ("; sent for approval again" if reopened else "")
849868
+ f" (api token {caller.token.prefix})"),
850869
source_ip=client_ip(request))
851870
if was_approved:

‎app/web/routes_pages.py‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2018,11 +2018,20 @@ def waivers(request: Request, q: str = "", status: str = "", page_no: int = 1,
20182018
Waiver.ticket.ilike(needle),
20192019
Waiver.requested_by.ilike(needle),
20202020
Waiver.finding_id.in_(matching)]
2021-
# A finding number on its own, as 4182 or #4182, is that finding.
2022-
number = query.lstrip("#").strip()
2023-
if number.isdigit():
2024-
either.append(Waiver.finding_id == int(number))
2025-
base = base.filter(sa_or(*either))
2021+
# A number on its own, as 4182, is that finding or the exception
2022+
# with that id. #4182 is only the finding, and E77 or
2023+
# "exception 77" is only the exception, for when both exist.
2024+
only_exception = re.fullmatch(r"(?:e|ex|exc|exception)\s*#?\s*(\d+)",
2025+
query.strip(), re.IGNORECASE)
2026+
if only_exception:
2027+
base = base.filter(Waiver.id == int(only_exception.group(1)))
2028+
else:
2029+
if query.strip().isdigit():
2030+
either.append(Waiver.id == int(query.strip()))
2031+
number = query.lstrip("#").strip()
2032+
if number.isdigit():
2033+
either.append(Waiver.finding_id == int(number))
2034+
base = base.filter(sa_or(*either))
20262035

20272036
total = base.count()
20282037
pages = max(1, (total + per - 1) // per)

‎app/web/templates/finding_detail.html‎

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,14 +1091,70 @@ <h1><span class="badge {{ f.severity.value }}"
10911091
{% endif %}
10921092

10931093
{% if waiver %}
1094-
<div class="card">
1094+
<div class="card" id="exception">
10951095
<div class="card-head">Exception</div>
10961096
<div class="card-body">
1097-
<p><span class="badge {{ 'pending' if waiver.status.value == 'requested' else 'ok' }}">{{ waiver.status.value }}</span></p>
1097+
<p><span class="badge {{ 'pending' if waiver.status.value == 'requested' else ('ok' if waiver.status.value == 'approved' else 'waived') }}">{{ waiver.status.value }}</span>
1098+
<span class="faint small mono">E{{ waiver.id }}</span></p>
10981099
<p>{{ waiver.reason }}</p>
10991100
<p class="small faint">Asked for by {{ waiver.requested_by }},
11001101
expires {{ waiver.expires_at | dt('%Y-%m-%d') if waiver.expires_at }}
11011102
{% if waiver.decided_by %}, decided by {{ waiver.decided_by }}{% endif %}</p>
1103+
{% if waiver.decision_note %}
1104+
<p class="small"><strong>{{ 'Why it was rejected' if waiver.status.value == 'rejected' else 'Note' }}:</strong>
1105+
{{ waiver.decision_note }}</p>
1106+
{% endif %}
1107+
{# The person who asked can change their own request until somebody
1108+
approves it, and take it back while it is still waiting. A rejected or
1109+
withdrawn one that is changed goes back for approval. #}
1110+
{% if can_waive and waiver.status.value != 'approved'
1111+
and (waiver.requested_by or '') | lower == (user.username or '') | lower %}
1112+
<details style="margin-top:.5rem" {{ 'open' if waiver.status.value == 'rejected' }}>
1113+
<summary class="small">{{ 'Change this request' if waiver.status.value == 'requested'
1114+
else 'Update and resubmit' }}</summary>
1115+
<form method="post" action="{{ base }}/waivers/{{ waiver.id }}/edit"
1116+
style="margin-top:.5rem;max-width:70ch">
1117+
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
1118+
<input type="hidden" name="back" value="finding">
1119+
{% if f.kind.value != 'credential' %}
1120+
<div class="field">
1121+
<label for="edit-category">Kind of exception</label>
1122+
<select id="edit-category" name="category">
1123+
<option value="" {{ 'selected' if not waiver.category }}>Accepted risk</option>
1124+
<option value="not_deployed" {{ 'selected' if waiver.category == 'not_deployed' }}>Not in scope: this code does not get deployed</option>
1125+
</select>
1126+
</div>
1127+
{% endif %}
1128+
<div class="field">
1129+
<label for="edit-reason">Why is this acceptable</label>
1130+
<textarea id="edit-reason" name="reason" required minlength="20"
1131+
maxlength="2000">{{ waiver.reason }}</textarea>
1132+
</div>
1133+
<div class="field">
1134+
<label for="edit-expires">Expires</label>
1135+
<input type="date" id="edit-expires" name="expires" required
1136+
min="{{ expiry_min }}" max="{{ expiry_max }}"
1137+
value="{{ waiver.expires_at | dt('%Y-%m-%d') if waiver.expires_at else default_expiry }}">
1138+
</div>
1139+
<div class="field">
1140+
<label for="edit-ticket">Ticket</label>
1141+
<input type="text" id="edit-ticket" name="ticket" maxlength="120"
1142+
value="{{ waiver.ticket or '' }}">
1143+
</div>
1144+
<button class="btn sm primary" type="submit">{{ 'Save' if waiver.status.value == 'requested'
1145+
else 'Resubmit for approval' }}</button>
1146+
</form>
1147+
</details>
1148+
{% if waiver.status.value == 'requested' %}
1149+
<form method="post" action="{{ base }}/waivers/{{ waiver.id }}/withdraw"
1150+
style="margin-top:.5rem"
1151+
data-confirm="Withdraw this request? You can ask again later.">
1152+
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
1153+
<input type="hidden" name="back" value="finding">
1154+
<button class="btn sm danger" type="submit">Withdraw request</button>
1155+
</form>
1156+
{% endif %}
1157+
{% endif %}
11021158
{% if can_revoke and waiver.status.value == 'approved' %}
11031159
<form method="post" action="{{ base }}/waivers/{{ waiver.id }}/revoke"
11041160
data-confirm="The finding comes straight back into both report files. Continue?">

‎app/web/templates/waivers.html‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
<div class="field" style="flex:1;min-width:220px">
2727
<label for="q">Search</label>
2828
<input type="text" id="q" name="q" value="{{ q }}"
29-
placeholder="finding number, GCR id, repository, path, reason, ticket or who asked">
29+
placeholder="finding number, exception id (E77), GCR id, repository, path, reason, ticket or who asked">
3030
</div>
3131
<div class="field">
3232
<label for="status">Status</label>
@@ -91,7 +91,7 @@
9191
<th class="nowrap"><input type="checkbox" id="bulk-select-page"
9292
title="Everything on this page you can act on"></th>
9393
{% endif %}
94-
<th class="nowrap" title="The finding number">#</th><th>Status</th><th>Finding</th><th>Why</th><th>Asked by</th><th></th></tr></thead>
94+
<th class="nowrap" title="The finding number, and the exception id under it">#</th><th>Status</th><th>Finding</th><th>Why</th><th>Asked by</th><th></th></tr></thead>
9595
<tbody>
9696
{% for w in rows %}
9797
{% set f = findings.get(w.finding_id) %}
@@ -107,8 +107,9 @@
107107
data-state="{{ w.status.value }}"
108108
{{ 'disabled' if not can_act }}></td>
109109
{% endif %}
110-
<td class="nowrap mono"><a href="{{ base }}/findings/{{ w.finding_id }}"
111-
title="Open finding {{ w.finding_id }}">{{ w.finding_id }}</a></td>
110+
<td class="stack nowrap mono"><a href="{{ base }}/findings/{{ w.finding_id }}"
111+
title="Open finding {{ w.finding_id }}">{{ w.finding_id }}</a>
112+
<span class="sub" title="The exception id, as the API and E{{ w.id }} in the search know it">E{{ w.id }}</span></td>
112113
<td class="stack nowrap">
113114
{% if w.status.value == 'approved' and w.expires_at and w.expires_at.replace(tzinfo=None) < now.replace(tzinfo=None) %}
114115
<span><span class="badge medium">expired</span></span>
@@ -143,7 +144,8 @@
143144
</td>
144145
<td class="stack nowrap">
145146
<span>{{ w.requested_by }}</span>
146-
{% if w.decided_by %}<span class="sub">decided by {{ w.decided_by }}</span>{% endif %}
147+
{% if w.decided_by %}<span class="sub">{{ w.status.value if w.status.value in ('approved', 'rejected') else 'decided' }} by {{ w.decided_by }}</span>{% endif %}
148+
{% if w.decision_note %}<span class="sub" style="white-space:normal;max-width:40ch">{{ w.decision_note }}</span>{% endif %}
147149
</td>
148150
<td class="nowrap">
149151
{% if can_approve and w.status.value == 'requested' %}
@@ -165,6 +167,22 @@
165167
<button class="btn sm danger" type="submit">Revoke</button>
166168
</form>
167169
{% endif %}
170+
{# The person who asked: change it until it is approved, and
171+
withdraw it while it still waits. The form is on the finding. #}
172+
{% if w.status.value != 'approved'
173+
and (w.requested_by or '') | lower == (user.username or '') | lower %}
174+
<a class="btn sm{{ ' primary' if w.status.value == 'rejected' }}"
175+
href="{{ base }}/findings/{{ w.finding_id }}#exception"
176+
title="Change the reason, expiry or ticket">{{ 'Change' if w.status.value == 'requested' else 'Update and resubmit' }}</a>
177+
{% if w.status.value == 'requested' %}
178+
<form method="post" action="{{ base }}/waivers/{{ w.id }}/withdraw"
179+
style="display:inline"
180+
data-confirm="Withdraw this request? You can ask again later.">
181+
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
182+
<button class="btn sm danger" type="submit">Withdraw</button>
183+
</form>
184+
{% endif %}
185+
{% endif %}
168186
</td>
169187
</tr>
170188
{% endfor %}

0 commit comments

Comments
 (0)