Findings API
Every scanner that can block a merge exposes its findings over the REST API. When a commit status says “merge blocked”, this is where you read the specific findings behind it, and where you dismiss the ones you accept.
This is the same data as /<org>/_admin/code-security in the admin UI — the UI is a client of these endpoints.
The path segments do not match the status names
This is the one thing worth memorising. The commit-status context and the API path segment differ:
| Scanner | Commit status you see on the PR | API path segment |
|---|---|---|
| Static analysis | fremforge/sast | sast |
| Dependencies | fremforge/dep-scan | deps |
| Container images | fremforge/image-scan | images |
| Licenses | fremforge/license-scan | licenses |
Guessing the segment from the status name returns 501 Not Implemented from the API’s catch-all, which reads as “documented but not yet shipped”. It is not — you have the wrong URL.
Base URL
The product API is served under /_app, not at the apex (the apex is Forgejo’s own API):
https://frem.sh/_app/api/v1/orgs/<org>/code-security/<scanner>/findingsAuthenticate with an org API token or a PAT — see the API reference for the token types.
List findings
Open findings only, by default:
curl -sS \
-H "Authorization: Bearer ${FREMFORGE_TOKEN}" \
"https://frem.sh/_app/api/v1/orgs/acme/code-security/images/findings?limit=50"{
"findings": [
{
"id": "0f6c…",
"registry_image": "acme/api@9f2c…",
"cve_id": "CVE-2026-59950",
"package": "mcp",
"severity": "high",
"fixed_version": "1.28.1",
"dismissed_at": null,
"created_at": "2026-07-25T06:33:41.940Z"
}
],
"has_more": false
}Query parameters, all four scanners:
limit— 1–200, default 50.offset— page withoffset += limituntilhas_moreisfalse.include_dismissed— settrueto see dismissed findings too.
The response fields differ per scanner, because the findings do: images returns cve_id / package / fixed_version, deps adds ecosystem / manifest_path / current_version and, for default-branch findings, via / dependency_path (which direct dependency brings the package in, see dependency paths), licenses returns license_id / category, and sast returns rule_id / path / line. The OpenAPI spec carries the exact schema for each.
When a finding closes
Findings tables are insert-only: every scan writes a fresh set of rows under a new scan run, and nothing is deleted when you fix the underlying problem. Two things close a finding.
Superseded automatically. An hourly job resolves findings whose scan run is no longer the latest for their target, marking them dismissed_by: auto-resolve. “Target” is the natural identity for each scanner:
| scanner | target identity |
|---|---|
sast | repo + file path |
deps, licenses | repo + manifest path |
images | image reference |
So the effective rule is “if the most recent scan of that target didn’t re-emit it, it’s gone” — the same semantics OSV and Trivy use. Fix it, let a later scan run, and it clears within the hour. Note the trigger is a newer scan of the same target, not the merge itself: if nothing re-scans that target, the row stays open indefinitely.
There is a deliberate safety property here — only the latest scan run supersedes. A misconfigured scan that emits nothing for a target does not wipe that target’s findings, so a broken scanner cannot silently clear your dashboard.
Dismissed explicitly. You dismiss it, with a reason. Manual dismissals are preserved by the auto-resolve pass, so your reason is never overwritten.
A dismissal can expire. Give it an expires_at (the API) or an Expires on date (the admin pages) and the acceptance lapses on its own: a daily job reopens the finding once the date passes, records on the audit log who had dismissed it and why, and — for a high or critical finding — sends the same security alert a new finding gets. A date means through that day, ending at midnight Europe/Copenhagen. Without a date a dismissal never expires, which is the default and how every earlier dismissal behaves. The expiry is carried forward with the dismissal across later scans, so a re-scan cannot turn a temporary acceptance into a permanent one.
What an image-scan dismissal covers. A dismissed container-image finding stays dismissed in later scans of the same repository or image, but only for the same CVE or rule in the same file (the finding’s target). If the same rule turns up in another file, including a file added later, that is a separate finding and it blocks on its own. So dismissing KSV-0014 on one manifest does not accept it for the next Deployment someone adds.
In practice: fix at source and let the next scan supersede it. Dismiss when you are accepting the finding, or when no further scan of that target is expected — for example a finding tied to a branch that has since merged and is no longer scanned.
Dismiss a finding
reason is required, is stored on the finding, and is written to the audit log — write it for whoever reads it in six months.
curl -sS -X POST \
-H "Authorization: Bearer ${FREMFORGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"reason":"Fixed at source in acme/api#412; stale PR-scoped row."}' \
"https://frem.sh/_app/api/v1/orgs/acme/code-security/images/findings/0f6c…/dismiss"{ "dismissed": true, "expires_at": null, "id": "0f6c…" }Add expires_at to make the acceptance temporary — a date ("2026-12-31", accepted through that day) or an ISO 8601 timestamp with a zone. It must be in the future and within three years; anything else is a 400 invalid-expires-at. The response echoes the stored instant:
{ "dismissed": true, "expires_at": "2026-12-31T23:00:00.000Z", "id": "0f6c…" }A 404 means no open finding with that id — most often it is already dismissed.
To close many at once, sast, deps and images accept up to 1000 ids per call:
curl -sS -X POST \
-H "Authorization: Bearer ${FREMFORGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"ids":["0f6c…","1a2b…"],"reason":"Accepted: vendored fixture, not shipped."}' \
"https://frem.sh/_app/api/v1/orgs/acme/code-security/deps/findings/bulk-dismiss"licenses has single dismissal only — there is no bulk endpoint for it.
Reading is not ingesting
Two similar-looking paths do different jobs:
/orgs/<org>/code-security/images/findings— what this page describes. Org-scoped, read and dismiss./_app/api/v1/image-scans/findings— the ingest endpoint the scanner runner POSTs results to. Not for reading.
Settings
Each scanner’s blocking threshold is also an API call — PUT /orgs/<org>/code-security/<scanner>/settings. Note that images gates on block_action while sast, deps and licenses gate on block_severity; the images threshold is the image_scan entry of the org scan policy (PUT /orgs/<org>/policy, see block threshold); licenses additionally takes allowlisted_license_ids.
See also
- Accepting findings in-repo — the other way to accept a finding: a Trivy ignore file committed alongside the code.
- Image scanning — how images get scanned and what blocks a merge.
- License scanning — policy categories and allowlisting.
- API reference — authentication, tokens, and the full OpenAPI spec.