Skip to content

Commit 07f041a

Browse files
committed
Exceptions and exclusions through the token API, and revoke from a finding
A reports token can now read one exception, and the person who asked can change or withdraw their own. Changing an approved one sends it back to waiting, and its finding returns to the report files until somebody agrees again. Nobody can read, change or withdraw an exception that is not theirs: it answers 404, and an administrator gets 403 trying to edit another person's. Administrators can revoke an approved exception, list exclusions by fingerprint or by finding, and revoke an exclusion by id or every rule on a fingerprint. A new approve scope, for administrators' tokens only and checked on every call, approves and rejects exceptions and adds exclusions. It is separate from reports so a pipeline token that leaks still cannot hide anything. Revoking an exclusion switches it off rather than deleting it, so it stays on the Exclusions page marked revoked. The finding page lists the exclusions covering it with a button to revoke each, and an approved exception there can be revoked too. The Approve, Reject and Revoke buttons on each row of the Exceptions page posted to routes that had gone missing. They are back, with the same scope checks the bulk actions use. The page also shows the finding number first, and its search takes a finding number, a GCR id or a title. The API reference, OpenAPI and Postman files, the pipeline sample kit, its README and the guide cover all of it, with two new bash scripts.
1 parent 06bdea2 commit 07f041a

20 files changed

Lines changed: 1527 additions & 66 deletions

‎app/core/api_reference.py‎

Lines changed: 226 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,13 @@ def _finding_filters():
7474
"this repository and branch to fix first."),
7575
("Findings", "Your vulnerabilities, credentials and code findings. "
7676
"Only repositories your account can open are included."),
77-
("Exceptions", "Ask for an exception and follow it. A request made with a "
78-
"token always waits for an administrator to approve it."),
77+
("Exceptions", "Ask for an exception, follow it, change it or withdraw it. "
78+
"A request made with a token always waits for an "
79+
"administrator to approve it. You can only read, change or "
80+
"withdraw your own, and an id that is not yours answers 404."),
81+
("Exclusions", "The rules that keep findings out of the report files, and "
82+
"taking one back. Administrators only. A token can revoke "
83+
"an exclusion but never add one."),
7984
("SBOMs", "Software bills of materials for the repositories you can see."),
8085
("Trends", "How your numbers have moved over time."),
8186
("Report files", "The whole estate report files. Administrators only."),
@@ -227,7 +232,9 @@ def endpoints() -> list:
227232
"your account approves exceptions.",
228233
"params": [_p("status", "Only this status.",
229234
choices=["requested", "approved", "rejected",
230-
"revoked", "expired"])] + PAGING,
235+
"revoked", "expired"]),
236+
_p("finding", "Only the exception on this finding number.",
237+
kind="integer")] + PAGING,
231238
"example": {"total": 1, "limit": 100, "offset": 0, "count": 1, "exceptions": [{
232239
"id": 77, "finding": 4182, "status": "requested", "category": "",
233240
"reason": "Only reachable from the admin network, fix planned in Q1.",
@@ -268,6 +275,222 @@ def endpoints() -> list:
268275
"example": {"id": 77, "finding": 4182, "status": "requested",
269276
"expires_at": "2027-01-31T00:00:00Z"},
270277
},
278+
{
279+
"id": "exception", "group": "Exceptions", "method": "GET",
280+
"path": "/exceptions/{exception_id}",
281+
"scope": "reports", "permission": "findings.read",
282+
"summary": "Read one exception",
283+
"description": "One of your own, or any on a finding you can see if "
284+
"your account approves exceptions.",
285+
"params": [_p("exception_id", "The exception number.", where="path",
286+
kind="integer", example=77)],
287+
"example": {"id": 77, "finding": 4182, "status": "approved", "category": "",
288+
"reason": "Only reachable from the admin network, fix planned in Q1.",
289+
"ticket": "SEC-1203", "requested_by": "jsmith",
290+
"requested_at": "2026-11-02T15:00:00Z",
291+
"expires_at": "2027-01-31T00:00:00Z", "decided_by": "admin",
292+
"decided_at": "2026-11-03T09:12:00Z", "decision_note": "",
293+
"vuln_id": "GCR-7Q2K9", "repository": "acme/payments",
294+
"title": "lodash prototype pollution"},
295+
},
296+
{
297+
"id": "edit_exception", "group": "Exceptions", "method": "PATCH",
298+
"path": "/exceptions/{exception_id}",
299+
"scope": "reports", "permission": "waiver.request",
300+
"summary": "Change your own exception",
301+
"description": "Send only the fields to change. Only the person "
302+
"who asked can change it, whatever their role. A "
303+
"waiting one stays waiting. An approved one goes "
304+
"back to waiting and its finding returns to the "
305+
"report files until an administrator approves it "
306+
"again. Answers 409 on one that is rejected or "
307+
"revoked: ask again instead.",
308+
"params": [_p("exception_id", "The exception number.", where="path",
309+
kind="integer", example=77)],
310+
"body": [
311+
{"name": "reason", "type": "string", "required": False,
312+
"description": "Why this is acceptable. At least 20 characters."},
313+
{"name": "expires", "type": "string", "required": False,
314+
"description": "When it runs out, as YYYY-MM-DD. Up to a year away."},
315+
{"name": "ticket", "type": "string", "required": False,
316+
"description": "A ticket number to tie it to."},
317+
{"name": "category", "type": "string", "required": False,
318+
"enum": ["", "not_deployed"],
319+
"description": "Empty for an accepted risk, or not_deployed."},
320+
],
321+
"body_example": {"expires": "2027-03-31", "ticket": "SEC-1300"},
322+
"example": {"id": 77, "finding": 4182, "status": "requested",
323+
"expires_at": "2027-03-31T00:00:00Z"},
324+
},
325+
{
326+
"id": "withdraw_exception", "group": "Exceptions", "method": "POST",
327+
"path": "/exceptions/{exception_id}/withdraw",
328+
"scope": "reports", "permission": "waiver.request",
329+
"summary": "Withdraw your own exception",
330+
"description": "Only the person who asked. Works on a waiting or "
331+
"approved one. It is kept as revoked with a note "
332+
"that it was withdrawn, and the finding comes "
333+
"straight back.",
334+
"params": [_p("exception_id", "The exception number.", where="path",
335+
kind="integer", example=77)],
336+
"example": {"id": 77, "finding": 4182, "status": "revoked",
337+
"decided_by": "jsmith",
338+
"decision_note": "Withdrawn by the person who asked for it."},
339+
},
340+
{
341+
"id": "revoke_exception", "group": "Exceptions", "method": "POST",
342+
"path": "/exceptions/{exception_id}/revoke",
343+
"scope": "reports", "permission": "waiver.revoke",
344+
"summary": "Revoke an approved exception",
345+
"description": "Administrators only, and only on findings they can "
346+
"see. The finding comes straight back into both "
347+
"report files, and the exclusion the approval made "
348+
"is switched off.",
349+
"params": [_p("exception_id", "The exception number.", where="path",
350+
kind="integer", example=77)],
351+
"example": {"id": 77, "finding": 4182, "status": "revoked",
352+
"decided_by": "admin"},
353+
},
354+
{
355+
"id": "approve_exception", "group": "Exceptions", "method": "POST",
356+
"path": "/exceptions/{exception_id}/approve",
357+
"scope": "approve", "permission": "waiver.approve",
358+
"summary": "Approve an exception",
359+
"description": "Needs the approve scope, which only an "
360+
"administrator's token can carry. Only on findings "
361+
"you can see. The finding leaves both report files "
362+
"and an exclusion is written so the next scan agrees. "
363+
"Answers 409 if it is not waiting on a decision.",
364+
"params": [_p("exception_id", "The exception number.", where="path",
365+
kind="integer", example=77)],
366+
"body": [{"name": "note", "type": "string", "required": False,
367+
"description": "A note kept with the decision."}],
368+
"body_example": {"note": "Agreed, revisit after the Q1 upgrade."},
369+
"example": {"id": 77, "finding": 4182, "status": "approved",
370+
"decided_by": "admin",
371+
"decision_note": "Agreed, revisit after the Q1 upgrade."},
372+
},
373+
{
374+
"id": "reject_exception", "group": "Exceptions", "method": "POST",
375+
"path": "/exceptions/{exception_id}/reject",
376+
"scope": "approve", "permission": "waiver.approve",
377+
"summary": "Reject an exception",
378+
"description": "Needs the approve scope. The finding stays in the "
379+
"report files and the person who asked sees why.",
380+
"params": [_p("exception_id", "The exception number.", where="path",
381+
kind="integer", example=77)],
382+
"body": [{"name": "note", "type": "string", "required": False,
383+
"description": "Why, for the person who asked."}],
384+
"body_example": {"note": "Upgrade instead, the fix is a patch release."},
385+
"example": {"id": 77, "finding": 4182, "status": "rejected",
386+
"decided_by": "admin"},
387+
},
388+
{
389+
"id": "add_exclusion", "group": "Exclusions", "method": "POST",
390+
"path": "/exclusions",
391+
"scope": "approve", "permission": "exclusion.write",
392+
"summary": "Exclude a fingerprint or a finding",
393+
"description": "Needs the approve scope. Send fingerprint to "
394+
"exclude that value wherever it appears, the same as "
395+
"\"Exclude this value\" on a finding, or add "
396+
"organization and repository to keep it to one "
397+
"place. Or send finding to exclude one finding, with "
398+
"by_value true to cover its value everywhere instead. "
399+
"Answers 409 if a rule already covers it.",
400+
"params": [],
401+
"body": [
402+
{"name": "fingerprint", "type": "string", "required": False,
403+
"description": "The hash off the finding, on its own or with "
404+
"cred: in front. This or finding."},
405+
{"name": "finding", "type": "integer", "required": False,
406+
"description": "A finding number. This or fingerprint."},
407+
{"name": "by_value", "type": "boolean", "required": False,
408+
"description": "With finding: cover its value everywhere."},
409+
{"name": "reason", "type": "string", "required": True,
410+
"description": "Why. At least 20 characters."},
411+
{"name": "expires", "type": "string", "required": False,
412+
"description": "YYYY-MM-DD, up to a year away. Empty for no end."},
413+
{"name": "ticket", "type": "string", "required": False,
414+
"description": "A ticket number to tie it to."},
415+
{"name": "organization", "type": "string", "required": False,
416+
"description": "With fingerprint: only in this organization."},
417+
{"name": "repository", "type": "string", "required": False,
418+
"description": "With fingerprint: only in this repository, "
419+
"by full name."},
420+
],
421+
"body_example": {"fingerprint": "cred:4f1c9a0b7e2d6c35",
422+
"reason": "Test key from the vendor docs, never valid.",
423+
"ticket": "SEC-1188"},
424+
"status": 201,
425+
"example": {"id": 31, "type": "secret", "fingerprint": "cred:4f1c9a0b7e2d6c35",
426+
"organization": "*", "repository": "*", "in_force": True,
427+
"revoked": False, "findings_excluded": 3},
428+
},
429+
{
430+
"id": "exclusions", "group": "Exclusions", "method": "GET",
431+
"path": "/exclusions",
432+
"scope": "reports", "permission": "exclusion.read",
433+
"summary": "List exclusions",
434+
"description": "The rules the Exclusions page shows your account. "
435+
"Use fingerprint to find what \"Exclude this value\" "
436+
"wrote, or finding to see what is hiding one finding.",
437+
"params": [
438+
_p("fingerprint", "Only rules on this value. The hash on its "
439+
"own or with cred: in front.", example="cred:4f1c9a0b7e2d6c35"),
440+
_p("finding", "Only the rules in force that cover this finding "
441+
"number.", kind="integer"),
442+
_p("status", "Only these.", choices=["live", "expired", "revoked"]),
443+
] + PAGING,
444+
"example": {"total": 1, "limit": 100, "offset": 0, "count": 1,
445+
"exclusions": [{"id": 31, "type": "secret", "vuln_id": "", "organization": "*",
446+
"repository": "*", "path": "*", "rule": "*",
447+
"fingerprint": "cred:4f1c9a0b7e2d6c35", "reason": "Test key from the vendor docs.",
448+
"ticket": "SEC-1188", "reviewed_by": "admin",
449+
"reviewed_date": "2026-10-20", "expires": "", "in_force": True,
450+
"revoked": False}]},
451+
},
452+
{
453+
"id": "revoke_exclusion", "group": "Exclusions", "method": "POST",
454+
"path": "/exclusions/{exclusion_id}/revoke",
455+
"scope": "reports", "permission": "exclusion.write",
456+
"summary": "Revoke one exclusion",
457+
"description": "Switches the rule off. It stays on the Exclusions "
458+
"page marked revoked. Everything it covered comes "
459+
"back into the report files, unless an approved "
460+
"exception still holds it. Answers 409 if it is "
461+
"already revoked.",
462+
"params": [_p("exclusion_id", "The exclusion number.", where="path",
463+
kind="integer", example=31)],
464+
"example": {"id": 31, "type": "secret", "vuln_id": "", "organization": "*",
465+
"repository": "*", "path": "*", "rule": "*",
466+
"fingerprint": "cred:4f1c9a0b7e2d6c35", "reason": "Test key from the vendor docs.",
467+
"ticket": "SEC-1188", "reviewed_by": "admin",
468+
"reviewed_date": "2026-10-20", "expires": "", "in_force": False,
469+
"revoked": True},
470+
},
471+
{
472+
"id": "revoke_fingerprint", "group": "Exclusions", "method": "POST",
473+
"path": "/exclusions/revoke",
474+
"scope": "reports", "permission": "exclusion.write",
475+
"summary": "Revoke every exclusion on a fingerprint",
476+
"description": "Takes back \"Exclude this value\" in one call: every "
477+
"rule in force that you can see and that names this "
478+
"value is switched off. Answers 404 when there is none.",
479+
"params": [],
480+
"body": [
481+
{"name": "fingerprint", "type": "string", "required": True,
482+
"description": "The hash off the finding, on its own or with "
483+
"cred: in front."},
484+
],
485+
"body_example": {"fingerprint": "cred:4f1c9a0b7e2d6c35"},
486+
"example": {"fingerprint": "cred:4f1c9a0b7e2d6c35", "count": 1,
487+
"revoked": [{"id": 31, "type": "secret", "vuln_id": "", "organization": "*",
488+
"repository": "*", "path": "*", "rule": "*",
489+
"fingerprint": "cred:4f1c9a0b7e2d6c35", "reason": "Test key from the vendor docs.",
490+
"ticket": "SEC-1188", "reviewed_by": "admin",
491+
"reviewed_date": "2026-10-20", "expires": "", "in_force": False,
492+
"revoked": True}]},
493+
},
271494
{
272495
"id": "repositories", "group": "SBOMs", "method": "GET",
273496
"path": "/repositories",

‎app/core/api_tokens.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@
3838
# that leaks cannot be used to pull every report, and an editor token
3939
# cannot be used to run gate checks.
4040
"gate": "Pull requests: run the gate check before a merge",
41+
# Signing off: approving and rejecting exceptions, and writing
42+
# exclusions. These take findings out of the report files, so they are
43+
# their own scope rather than part of reports. A pipeline token that
44+
# leaks still cannot hide anything, and only an administrator's token can
45+
# carry this one. The permission is checked again on every call.
46+
"approve": ("Administrators: approve or reject exceptions and add "
47+
"exclusions"),
4148
# External sites and their dynamic scans. Only an administrator's token
4249
# can use it, since the pages are theirs alone, and the check is made on
4350
# every call rather than when the token is made.
@@ -66,6 +73,11 @@ def create(session, user, name: str, scopes, days: int = DEFAULT_DAYS):
6673

6774
if not rbac.can(user, "external.manage"):
6875
raise ValueError("Only an administrator's token can work with external sites.")
76+
if "approve" in wanted:
77+
from app.core import rbac
78+
79+
if not (rbac.can(user, "waiver.approve") and rbac.can(user, "exclusion.write")):
80+
raise ValueError("Only an administrator's token can approve exceptions.")
6981
if not wanted:
7082
raise ValueError("Pick at least one thing it may do.")
7183

‎app/core/exclusions.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,68 @@ def active_rules(session, now: datetime = None) -> list:
8585
log = logging.getLogger("gcr.exclusions")
8686

8787

88+
def finding_kind(finding) -> str:
89+
"""Which exclusion bucket a finding falls in."""
90+
value = getattr(finding.kind, "value", str(finding.kind or "")).lower()
91+
if "cred" in value or "secret" in value:
92+
return "secret"
93+
if "code" in value:
94+
return "code"
95+
return "vuln"
96+
97+
98+
def matches(rule, finding) -> bool:
99+
"""Whether a rule covers this finding, the way ingest applies it.
100+
101+
A rule written against one finding id covers that finding and nothing
102+
else. The rest go by kind, place and rule, with * for any, and by value
103+
when the rule names a fingerprint.
104+
"""
105+
import fnmatch
106+
107+
if rule.vuln_id:
108+
return rule.vuln_id.strip().upper() == (finding.vuln_id or "").upper()
109+
if (rule.kind or "any") not in ("any", finding_kind(finding)):
110+
return False
111+
for field, pattern, value in (
112+
("organization", rule.organization, finding.organization or ""),
113+
("repository", rule.repository, finding.repository or ""),
114+
("path", rule.path, finding.file_path or ""),
115+
("rule", rule.rule, finding.rule or "")):
116+
pattern = pattern or "*"
117+
if pattern == "*":
118+
continue
119+
# A repository named without its organization matches on the name.
120+
if field == "repository" and "/" not in pattern:
121+
value = value.split("/")[-1]
122+
if not fnmatch.fnmatch(value.lower(), pattern.lower()):
123+
return False
124+
if rule.fingerprint:
125+
return rule.fingerprint == finding.fingerprint
126+
return True
127+
128+
129+
def covering(session, finding, rules=None) -> list:
130+
"""The live rules that cover this finding, newest first."""
131+
pool = active_rules(session) if rules is None else rules
132+
return [rule for rule in pool if rule.live() and matches(rule, finding)]
133+
134+
135+
def revoke(rule, actor: str) -> bool:
136+
"""Switch a rule off and keep it on the record. False if it was off already.
137+
138+
Unlike deleting it, the row stays: the Exclusions page shows it as revoked,
139+
with the reason it was written, until somebody purges it. The audit log
140+
says who did it. Whoever calls this has to put the exception flags right
141+
afterwards.
142+
"""
143+
if not rule.active:
144+
return False
145+
rule.active = False
146+
log.info("exclusion %s revoked by %s", rule.id, actor)
147+
return True
148+
149+
88150
def to_csv(session, rules=None) -> str:
89151
"""The rules as the scanner's CSV.
90152

0 commit comments

Comments
 (0)