@@ -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" ,
0 commit comments