Skip to main content

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/findings

The runner PAT needs the findings:write scope (minted per-repo by the runner controller at workflow-install time). On a successful ingest, fremforge:

  1. 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_number null) live in their own bucket so they don’t cross-replace PR scans.
  2. Posts a PR comment summarising the findings (severity, licence, package, manifest), via the Forgejo issues API.
  3. Sets the fremforge/license-scan commit status on the PR head SHA — success when advisory-only or all-allowlisted, failure when 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.

EcosystemSourceWhat is read
npm (npm / pnpm / yarn / bun lockfiles)registry.npmjs.org, through the dependency proxythe package’s license field
Go (go.mod)proxy.golang.org, through the dependency proxy; private modules (frem.sh, your declared GOPRIVATE) are never requestedthe 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 proxythe POM’s <licenses>, following relocations and parent POMs
NuGet (packages.lock.json)nuget.org, through the egress proxythe package’s licence expression, or a recognisable licence URL
PyPI (requirements.txt, Poetry, uv)pypi.org, through the dependency proxythe release’s JSON metadata (license_expression, a short license, licence classifiers)
crates.io, RubyGems, Packagistthe public registry, through the egress proxythe 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:

BucketMeaningExample licences
unknownLicence could not be determined (still surfaced for review)NOASSERTION
unencumberedPublic-domain-equivalentCC0-1.0, Unlicense
permissiveUse freely, minimal obligations—
noticePermissive with attribution / notice obligationsMIT, BSD-3-Clause, Apache-2.0, ISC
reciprocalWeak copyleft — changes to the library itself are sharedLGPL-2.1, LGPL-3.0, MPL-2.0, EPL-2.0, CDDL-1.0, GPL-2.0 WITH Classpath-exception-2.0
restrictedStrong copyleftGPL-2.0, GPL-3.0, CC-BY-SA-4.0
forbiddenNetwork copyleft / source-availableAGPL-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

One licence policy since 2026-10-15. The lists, the unknown and multiple licence rules and the block severity are edited at Security › Policies › Licence policy, together with the licence rules for package downloads. See Licence policy. Security › Findings › Licences keeps the on/off switch and the per-repository overrides. The API fields below still work and change pull requests only.

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 was forbidden, 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 the fremforge/license-scan check 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.

SettingAPI fieldDefaultEffect
Allow listallowlisted_license_idsemptyAn operand on it is allowed: the finding is dismissed at ingest (actor tenant_allowlist), whatever its bucket.
Deny listdenied_license_idsemptyAn operand on it blocks, whatever its bucket. Deny wins over allow for the same id.
Warn listwarned_license_idsemptyAn operand on it is advisory: reported, never blocking, whatever its bucket.
Unknown licencesunknown_actionthresholdA 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 licencesmultiple_modeallA 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/evaluate

Per-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