License scanning
License scanning answers the what am I allowed to ship question — complementing the SBOM’s what’s in this image and the dependency scan’s what’s vulnerable. fremforge runs Trivy licence detection on every PR, classifies each resolved dependency’s licence into an SPDX-policy severity bucket, and surfaces the result next to the PR and in a dedicated org dashboard. It is on by default for every org — no enable step.
How it works
fremforge’s central scanner (the same one that runs SAST, dependency scanning, and image scanning) reads the licence of every dependency your lockfiles resolve, and runs Trivy’s licence-file detection over your repository’s own LICENSE/COPYING files, on each PR. It runs on fremforge’s own runners — nothing is installed in your repository. Unlike SAST or dependency scanning — which post a problems-only list — license scanning posts a full licence inventory: one finding per (package × licence) across every resolved dependency. The workflow then POSTs the inventory to fremforge:
POST https://frem.sh/_app/api/v1/license-scan/findingsThe runner PAT needs the findings:write scope (minted per-repo by the runner controller at workflow-install time). On a successful ingest, fremforge:
- Stores the findings. One row per finding in
license_scan_findings. Each scan supersedes the prior scan for the same(tenant, repo, pr_number)wholesale — a PR that drops a dependency stops surfacing its stale rows. Push scans (pr_numbernull) live in their own bucket so they don’t cross-replace PR scans. - Posts a PR comment summarising the findings (severity, licence, package, manifest), via the Forgejo issues API.
- Sets the
fremforge/license-scancommit status on the PR head SHA —successwhen advisory-only or all-allowlisted,failurewhen the policy gate is hit.
The Forgejo-side comment and status are best-effort: a Forgejo outage logs and downgrades to advisory but never fails the ingest. The stored findings are the source of truth; the PR surface is a convenience layer.
Where dependency licences come from
Nothing is installed: licences are read from registry metadata for the exact version each lockfile pins. For npm, PyPI, Go and Maven, that metadata is read through your organisation’s dependency proxy, on the scan service’s internal route, or through fremforge’s shared scan cache when your organisation has no proxy; only when neither can answer is it read from the public registry through fremforge’s egress proxy. NuGet, crates.io, RubyGems and Packagist licences are read from the public registry through the egress proxy. fremforge’s former in-cluster package mirrors are retired.
| Ecosystem | Source | What is read |
|---|---|---|
| npm (npm / pnpm / yarn / bun lockfiles) | registry.npmjs.org, through the dependency proxy | the package’s license field |
Go (go.mod) | proxy.golang.org, through the dependency proxy; private modules (frem.sh, your declared GOPRIVATE) are never requested | the module’s root LICENSE / COPYING file, classified by the same licence-file detection as your own files (Go has no licence metadata) |
Maven (pom.xml, Gradle lockfiles) | Maven Central, through the dependency proxy | the POM’s <licenses>, following relocations and parent POMs |
NuGet (packages.lock.json) | nuget.org, through the egress proxy | the package’s licence expression, or a recognisable licence URL |
PyPI (requirements.txt, Poetry, uv) | pypi.org, through the dependency proxy | the release’s JSON metadata (license_expression, a short license, licence classifiers) |
| crates.io, RubyGems, Packagist | the public registry, through the egress proxy | the registry’s per-version licence field |
Free-text licence names (GPL v3, GNU AFFERO GPL 3.0, Apache License, Version 2.0, …) are mapped to SPDX ids. Anything that cannot be read with confidence is reported as unknown and surfaced for review, never guessed permissive.
When a source is down
If a mirror or registry does not answer (network error, timeout, 5xx), the affected packages are neither read as clean nor allowed to block: the fremforge/license-scan status keeps whatever the rest of the scan earned and adds lookup-unavailable for N package(s), re-scan queued. fremforge re-runs the scan automatically every few minutes until the source answers, and the note disappears.
Severity buckets
License scanning uses a 7-bucket severity vocabulary (not the 4-tier CRITICAL/HIGH/… used by CVE scanning), matching Trivy’s SPDX-policy classification — ordered from least to most encumbering:
| Bucket | Meaning | Example licences |
|---|---|---|
unknown | Licence could not be determined (still surfaced for review) | NOASSERTION |
unencumbered | Public-domain-equivalent | CC0-1.0, Unlicense |
permissive | Use freely, minimal obligations | — |
notice | Permissive with attribution / notice obligations | MIT, BSD-3-Clause, Apache-2.0, ISC |
reciprocal | Weak copyleft — changes to the library itself are shared | LGPL-2.1, LGPL-3.0, MPL-2.0, EPL-2.0, CDDL-1.0, GPL-2.0 WITH Classpath-exception-2.0 |
restricted | Strong copyleft | GPL-2.0, GPL-3.0, CC-BY-SA-4.0 |
forbidden | Network copyleft / source-available | AGPL-3.0, SSPL-1.0, BUSL-1.1, CC-BY-NC-4.0 |
LGPL is reciprocal in fremforge (Trivy’s own table puts it in restricted): a program may link an LGPL library without itself becoming LGPL, so it is weak copyleft and does not block at the default threshold.
Policy controls
Configure on the licence policy page, through the API (PUT /orgs/<org>/code-security/licenses/settings, pull requests only) or as code with the Terraform and OpenTofu module:
- Enabled — when off, ingest returns
202 {accepted: 0, status: 'disabled'}(the workflow still runs, the findings are just dropped). Default-on. - Block severity — the threshold at or above which a finding blocks merge. Default
restricted(since 2026-09-29; it wasforbidden, which let GPL dependencies through as advisory): strong copyleft and above blocks, weak copyleft stays advisory. A licence on none of the lists below is judged by this threshold. Combine with branch protection requiring thefremforge/license-scancheck to actually block merge. - Licence policy — explicit lists and two rules, described in the next section.
Findings at forbidden (→ critical) or restricted (→ high), or denied by the deny list, that are newly introduced and not dismissed by a scan also fire the real-time security-alert hook; lower buckets are advisory only.
Licence policy
Every finding is decided per SPDX operand, with the same reading the package policy uses: in MIT OR GPL-3.0-only the consumer may choose, so the least restrictive operand decides; in Apache-2.0 AND GPL-2.0-only both apply, so the most restrictive operand decides. Nested parentheses, WITH exceptions (GPL-2.0-only WITH Classpath-exception-2.0) and LicenseRef-… ids are read as SPDX defines them.
| Setting | API field | Default | Effect |
|---|---|---|---|
| Allow list | allowlisted_license_ids | empty | An operand on it is allowed: the finding is dismissed at ingest (actor tenant_allowlist), whatever its bucket. |
| Deny list | denied_license_ids | empty | An operand on it blocks, whatever its bucket. Deny wins over allow for the same id. |
| Warn list | warned_license_ids | empty | An operand on it is advisory: reported, never blocking, whatever its bucket. |
| Unknown licences | unknown_action | threshold | A licence with no SPDX id, NOASSERTION, NONE, UNKNOWN, a string that is not an SPDX expression, or an id fremforge cannot classify (such as an unlisted LicenseRef-…): threshold judges it as the unknown bucket under the block severity; allow dismisses it, warn reports it, deny blocks it. |
| Several licences | multiple_mode | all | A component that declares several licences (separate findings, not one expression): all judges each licence on its own, so any one can block; any lets the least restrictive one decide for all of them. |
List entries are SPDX ids (MIT, LicenseRef-acme) or an id with its exception (GPL-2.0-only WITH Classpath-exception-2.0), matched case-insensitively; GPL-2.0+ matches GPL-2.0-or-later. An entry for the base licence covers it with any exception; an entry for the pair covers only the pair. Up to 100 entries per list. A PUT replaces the settings: a field left out takes its default.
The defaults change nothing. With empty deny and warn lists, unknown_action: threshold and multiple_mode: all, every finding is decided exactly as the block severity and the allowlist decided it before this policy existed. The one difference is the allowlist itself, which now matches operands: MIT OR GPL-3.0-only passes an allowlist that holds MIT.
Why a finding blocks
On a pull request, only findings the pull request introduces block: a package and licence the branch already has does not block.
Each finding carries the decision as policy in the findings API (GET /orgs/<org>/code-security/licenses/findings), and the dashboard shows it in the Policy decision column:
{
"license_id": "MIT OR GPL-3.0-only",
"policy": {
"action": "allow",
"rule": "allow_list",
"decided_by": ["MIT"],
"operands": [
{ "operand": "MIT", "action": "allow", "rule": "allow_list", "matched": "MIT" },
{ "operand": "GPL-3.0-only", "action": "block", "rule": "deny_list", "matched": "GPL-3.0-only" }
],
"why": "allowed: MIT is on the allow list (OR takes the least restrictive operand)"
}
}action is allow, warn or block; rule is allow_list, deny_list, warn_list, threshold, below_threshold, unknown, multiple_any or exception. The PR comment lists each blocking finding with its reason, and the fremforge/license-scan status names the rules (2 finding(s) blocked by the licence policy (1 denied, 1 at or above restricted)).
To try a policy before a PR does, POST /orgs/<org>/code-security/licenses/evaluate decides a list of licences without writing anything:
curl -sS -X POST -H "Authorization: Bearer $FREMFORGE_TOKEN" -H 'Content-Type: application/json' \
-d '{"licenses":[{"license_id":"MIT OR GPL-3.0-only"},{"license_id":"Apache-2.0 AND GPL-2.0-only"}]}' \
https://frem.sh/_app/api/v1/orgs/<org>/code-security/licenses/evaluatePer-repository overrides
A repository can override any of the five policy fields: PUT /orgs/<org>/repos/<repo>/code-security/licenses/settings with the fields to set. A field that is set replaces the organisation’s value for that repository (a list replaces the organisation’s list; an empty array is a value); a field that is absent or null inherits it. GET on the same path shows the override and the effective policy, DELETE removes it. In the console, overrides are under Per-repository overrides on Security › Policies › Licence policy.
Licence exceptions for one component
To accept one licence for one component — say, a GPL tool your legal team has cleared — request an exception (POST /orgs/<org>/exceptions with kind: package, or the console) with scope component, rule licence, the component’s purl without a version (pkg:npm/left-pad, pkg:pypi/requests, pkg:maven/org.example:lib) and the licence id as the selector (leave the selector empty for any licence of that component). It is the same exception as for package policies: a justification, a mandatory expiry of at most a year, and approval by an owner or named approver who is not the requester. Once approved (POST /orgs/<org>/exceptions/<id>/approve, by someone other than the requester), the next scan of any repository dismisses the matching findings (actor licence-exception:<id>, dismissal_approved_by the approver, reason with the justification), until the exception expires (the finding reopens that day) or is revoked (the next scan reopens it).
Dismissing findings
An org admin can dismiss individual findings (with a required reason, written to the audit log) from the Licenses tab. Manual dismissals are carried forward across the supersede-on-ingest re-scan, keyed on (licence ID, package, manifest path) — re-scanning a PR doesn’t resurrect a finding an admin already accepted. A dismissal may carry an optional expiry date, after which the finding reopens on its own — see when a finding closes. Dismissals by the licence policy (allow list, unknown allow, multiple_mode: any, an exception) are not carried forward; they are re-derived from the settings on every scan, so changing the policy takes effect on the next scan.
Where findings surface
Security › Findings › Licences lists active findings and recent dismissals. Each finding shows the severity bucket, the SPDX licence ID, the ecosystem:package, the manifest path and the policy decision with its reason. The PR comment links back to this dashboard.
Retention
Findings follow the per-tenant audit-retention policy, consistent with the other code-security scanners. Closed-PR findings are swept by the daily /jobs/license-scan-closed-pr-cleanup job, since a closed PR never re-scans.
See also
- OSV recommendations — dependency CVE scanning (the vulnerability complement to this licence view).
- SBOMs — the package inventory the licences attach to.
- Image scanning — the sibling scanner that shares the same central scan run and
findings:writeingest pattern. - Security features at a glance — the full control matrix.