Skip to main content

Exceptions

An exception accepts a risk for a limited time: a vulnerability you cannot fix yet, a licence finding on an internal tool, a secret in a test fixture. It works the same way for every kind of finding (dependencies, container images, SAST, licences, secrets, malware in dependencies, registry packages) and for package-policy exceptions.

An exception takes effect only when a second person approves it. Until then the finding stays open and the policy still applies.

How an exception works

  1. Request. Anyone who can work with findings asks for one, with a justification and an expiry date. The date may be at most your organisation’s cap away (366 days unless you set a lower one).
  2. Approve or reject. An approver other than the requester decides, with an optional comment. The approvers are emailed when a request arrives, in their own language.
  3. In force. The finding is dismissed until the expiry date. The finding records who asked (dismissed_by) and who approved (dismissal_approved_by). A rescan keeps the dismissal.
  4. The end. On its expiry date the finding opens again, and the exception is marked expired.

The states are requested, approved, rejected, expired and revoked.

Who can approve

  • By default: the organisation’s owners and the people on the package-policy approver list.
  • Or, if you name an approver team in the settings: only the members of that team.

Nobody can approve their own request: the console shows them no Approve button, and the API answers self-approve. A token with no person behind it, and an AI agent’s credential, cannot decide an exception at all (user-identity-required).

Automatic revocation

An approved exception is revoked, and its finding opened again at once, when:

TriggerRevocation reason
Someone revokes it by handmanual
The finding’s severity rises above what was approvedseverity_raised
A new vulnerability appears on the same component (same package and version, or same image package)new_vulnerability
The approver leaves the organisationapprover_left

The approver leaving is acted on when the membership change happens; the other checks run every hour.

Audit and SIEM

Every step is a tenant audit event: code-security.exception.requested, .approved, .rejected, .revoked and .expired for findings, and package_policy.exception.* for package policies. A revocation event carries its reason and what changed. Like every audit event, they reach your SIEM endpoints; filter on the action prefix code-security.exception. to receive only these.

In the console

Open Security → Findings → Exceptions. The page has:

  • Waiting for approval: the pending requests, each with Approve and Reject and a comment box. Your own requests show no Approve button.
  • In force: approved exceptions, with Revoke.
  • History: rejected, revoked and expired exceptions, with who decided, the comment and, for a revocation, why.
  • Request an exception for a finding: on any finding’s page, the dismiss form has a Request an exception link that fills this in for you.
  • Settings: the longest an exception may run, the approver team, and Dismissals require approval.

API

Base URL https://frem.sh/_app/api/v1. See the API reference for the full shapes.

Method and pathScopeWhat it does
GET /orgs/{org}/exceptionsfindings:readEvery exception, newest first. Filter with status (comma-separated) and kind (finding or package).
POST /orgs/{org}/exceptionsfindings:writeRequest one. For a finding: scanner, finding_id, justification, expires_at. For a package policy: "kind": "package" and the package-policy exception fields.
GET /orgs/{org}/exceptions/{id}findings:readOne exception.
POST /orgs/{org}/exceptions/{id}/approvefindings:writeApprove, with an optional {"comment": "..."}.
POST /orgs/{org}/exceptions/{id}/rejectfindings:writeReject, with an optional comment.
POST /orgs/{org}/exceptions/{id}/revokefindings:writeWithdraw a request, or revoke an approved exception.
GET, PUT /orgs/{org}/exceptions/settingspolicy:read, policy:writemax_days (1 to 366), approver_team (a team name, or null for owners plus the approver list) and dismissals_require_approval (true or false).

scanner is one of deps, images, sast, licenses, secrets, package_malware and packages; finding_id is the id the findings endpoints return. expires_at is a date (YYYY-MM-DD, through that day UTC) or an ISO 8601 time. A second request for a finding that already has a pending or approved exception answers 409 duplicate-pending.

Request an exception for a dependency finding:

curl -sS -X POST https://frem.sh/_app/api/v1/orgs/acme/exceptions \
  -H "Authorization: Bearer $FREMFORGE_TOKEN" -H 'Content-Type: application/json' \
  -d '{"scanner":"deps","finding_id":"<id>","justification":"Not reachable from user input; fixed by the framework upgrade.","expires_at":"2026-12-31"}'

A licence exception for the package policy:

curl -sS -X POST https://frem.sh/_app/api/v1/orgs/acme/exceptions \
  -H "Authorization: Bearer $FREMFORGE_TOKEN" -H 'Content-Type: application/json' \
  -d '{"kind":"package","scope":"package","rule_kind":"licence","rule_selector":"GPL-3.0-only","pkg_type":"npm","name":"left-pad","justification":"Internal tool, never distributed.","expires_at":"2026-12-31"}'

Settings as code

PUT /orgs/{org}/exceptions/settings is a plain idempotent write, so the cap and the approver team can live in your configuration repository next to the rest of your organisation settings:

curl -sS -X PUT https://frem.sh/_app/api/v1/orgs/acme/exceptions/settings \
  -H "Authorization: Bearer $FREMFORGE_TOKEN" -H 'Content-Type: application/json' \
  -d '{"max_days": 90, "approver_team": "security"}'

One-person dismissal

The dismiss button on a finding still exists. It records who dismissed and why, and can carry an expiry date, but it has no second person and no automatic revocation. Use an exception when you need four eyes, or switch on Dismissals require approval to make every dismissal by someone who is not an approver go through one.

Dismissals require approval

A per-organisation setting, off by default. When it is on, a dismissal of a dependency, SAST, secret, container-image, licence or dependency-malware finding by someone who is not an approver dismisses nothing. Instead:

  • each open finding gets an exception request, with the dismissal’s reason as the justification and its expiry date (your organisation’s cap when the dismissal had none);
  • the finding stays open, and the approvers are emailed;
  • an approver other than the person who dismissed approves or rejects it in the queue. Approval dismisses the finding with both names, exactly like any other exception. Nobody can approve their own dismissal.

Approvers still dismiss directly. In the console, a dismissal that went for approval takes you to the exceptions queue with a notice. Over the API, every dismiss and bulk-dismiss endpoint then answers 202 instead of 200:

{
  "dismissed": false,
  "status": "pending_approval",
  "pending_count": 1,
  "exceptions": [
    { "id": "<exception id>", "finding_id": "<finding id>", "status": "pending_approval", "expires_at": "2027-10-11T15:00:00.000Z" }
  ],
  "skipped": []
}

skipped lists findings that were not queued: not-found (not open, or not yours) or duplicate-pending (it already has a pending or approved exception). A single-finding dismissal answers 404 or 409 duplicate-pending for those instead. An expiry date beyond your cap is refused with 400 invalid-expires-at.

A malware finding on an uploaded file (Security → Findings → Malware scanning) has no reason or expiry to put through the queue. While the setting is on, only an approver can dismiss one; anyone else gets 403 not-an-approver.

Turn it on in Security → Findings → Exceptions → Settings, over the API:

curl -sS -X PUT https://frem.sh/_app/api/v1/orgs/acme/exceptions/settings \
  -H "Authorization: Bearer $FREMFORGE_TOKEN" -H 'Content-Type: application/json' \
  -d '{"dismissals_require_approval": true}'

or with exception_settings in the Terraform module. Switching it on or off writes the audit events exceptions.dismissal_approval.enabled and .disabled. A request made from a dismissal is the ordinary code-security.exception.requested event with "via": "dismissal". Requests already waiting stay in the queue when you switch it off, and can still be approved.