Skip to main content

Model scanning

A model file can run code when it is loaded. A PyTorch checkpoint is a Python pickle, and unpickling it can import and call anything. Keras Lambda layers, GGUF chat templates and ONNX custom operators are other ways in. fremforge scans model files wherever they land in your organisation and keeps one verdict per file. Your organisation’s policy decides what happens to a file the scan finds.

No model is ever loaded or run to scan it. The scanner reads the file’s structure: the pickle’s import list, the safetensors and GGUF headers, the ONNX graph and the Keras configuration.

Where model files are found

SurfaceWhen it is scannedWhat enforce does
Git LFSOn uploadRefuses the upload with 422
Packages (generic, and every type whose upload body is the file)On uploadRefuses the upload with 422
Container registry (ModelPack, KitOps, Docker ai/ and model files inside ordinary images)After the pushRefuses a pull that would serve the layer with 403
Dependency proxy (container images)When it is first pulledRefuses later pulls of the layer with 403
Actions artifactsAfter the run finishesNothing. Artifacts get findings only

A file is recognised as a model by its first bytes, not by its name. A pickle saved as weights.txt is still found.

Formats and verdicts

FormatWhat is checked
Pickle, joblib, PyTorch (.pt, .pth, .bin, legacy and zip), NumPy object arraysEvery import in the pickle against an allowlist. The allowlist is what PyTorch’s torch.load(weights_only=True) accepts, plus the NumPy types a state dict needs
safetensorsThe header: lengths, data types and offsets, and that the file cannot also be opened as a zip or a pickle
GGUFThe metadata, the chat template (against a safe subset of Jinja), and the same zip and pickle checks
ONNXCustom operator domains, and external data paths that leave the model’s directory
Keras (.keras, .h5)Lambda layers and custom objects outside the Keras namespace

Each file gets one verdict, shared by every organisation that holds the same bytes:

  • clean: nothing found.
  • suspicious: an import outside the allowlist (pickle-unknown-import), a class from a known ML framework (pickle-framework-object, see Framework pickles), a GGUF template construct outside the safe subset, an ONNX custom operator, or a file that can also be opened as another format.
  • malicious: an import on the scanner’s blocklist (such as os.system or builtins.eval), or a Keras Lambda layer.
  • unscannable: the file could not be read to the end: a broken zip, a 7z archive, a truncated stream.
  • format-verified: a safetensors or GGUF file above 4 GiB whose structure check passed. See Above 4 GiB.

The scanner is open source: picklescan for the pickle walk, with fremforge’s allowlist on top.

Your organisation’s policy

The policy has three modes. Set it under Security › Policies › Model scanning, or with the API.

ModeWhat happens
Shadow (default)Every file is scanned and the files the policy would block are listed as findings. Nothing is refused.
EnforceAs shadow, and the files with an open finding are refused, as listed in Where model files are found.
OffFiles are still scanned and the findings are still listed, but nothing is refused or counted as would-block.

Under enforce, the policy blocks a file whose verdict is malicious, unscannable or suspicious, unless the finding is dismissed. A clean or format-verified file always passes.

What enforce looks like

An upload that is refused answers 422 with the verdict, the finding codes and the file’s sha256:

{
  "error": "model file rejected",
  "rule": "suspicious",
  "verdict": "suspicious",
  "findings": ["pickle-unknown-import"],
  "sha256": "d24ec017bdcf45a8737b4a9398e6eea64fa37a5821f473041cb3bc03f8489687"
}

A file that has not been scanned before is scanned during the upload. That adds at most a few seconds for a file up to 256 MiB. A safetensors or GGUF file above 256 MiB is checked by its header and its last 64 KiB. Any other model file above 256 MiB is accepted and scanned straight after the upload, so it shows up as a finding a minute later instead of being refused.

If the scanner cannot be reached, an upload of a model file in an enforcing organisation answers 503 with Retry-After: 30. Uploads of other files are not affected.

A container pull that would serve a blocked layer answers 403 DENIED, with the reason model-malicious, model-suspicious or model-unscannable and the layer’s digest. A model layer you have just pushed is scanned within about a minute. Until then a pull answers 403 with the reason model-scan-pending, the way an image with a layer that is still being malware-scanned does. Pulls by fremforge-hosted runners are not held for a pending scan.

git-lfs hides the reason. For a refused upload, git-lfs prints Uploading failed due to unsupported Content-Type header(s), and git lfs push --object-id exits 0. A normal git push does fail. The reason is in the response body, and the file is listed as a finding under Model scanning.

Framework pickles

Some checkpoints store whole Python objects, not only weights: a YOLO model, a TrainingArguments file, a scikit-learn pipeline. Loading one runs that class’s code. The scanner labels these pickle-framework-object when every import outside the weights allowlist comes from a known framework: PyTorch modules, Transformers, Ultralytics, scikit-learn, joblib, timm, Lightning, SpeechBrain or Diffusers.

They are suspicious like any other unknown import. To let them pass enforce, turn on Allow pickles of known ML frameworks (allow_framework_pickles). A file with any other finding is still blocked.

Dismissing a finding

An org owner can dismiss a finding for one file (one sha256), with a reason and an expiry date: 90 days by default, at most 365. A dismissed file passes enforce and leaves the findings list. Dismissals are per organisation; another organisation that holds the same bytes is not affected. A dismissal can be revoked at any time.

Above 4 GiB

The malware scanner, ClamAV, cannot read more than 4 GiB of one file, and fremforge refuses a file it cannot scan. For model weights, which often exceed 4 GiB, there is one exception, in every mode: a safetensors or GGUF file whose structure check passes is accepted as format-verified. These formats hold tensors and metadata only, and the structure check proves the file contains nothing else. The file is not malware-scanned.

Every other file above 4 GiB, including every pickle, is still refused. The limit for one file is 10 GiB. Large models are published as shards of 5 GB or less, so this is rarely the limit that matters.

Actions artifacts

When a workflow run finishes, the files it uploaded with actions/upload-artifact are checked for model files, and those are scanned. A finding is listed under Model scanning with the repository, the run number and the artifact. Artifacts are never refused or deleted: blocking an upload would fail your workflow with no way to override it.

AI inventory

Security › Components › AI inventory lists the machine-learning models your organisation holds and the ones its CI pulls. It is read-only; the policy and dismissals stay on the model scanning page.

Model files

One row per model file (per sha256) found in your Git LFS, packages, container images, dependency-proxy pulls and Actions artifacts, with:

  • its format and size, and its verdict (pending while it waits for its scan);
  • its licence, as it is declared (see Where licences come from), with where it was declared. A model with no declared licence shows unknown. fremforge does not guess a licence from a file name;
  • what its header states: parameter count, tensor data types, and for GGUF the architecture and model name;
  • where it occurs: each repository, package, image or artifact it was seen in.

Every model file is also in the component inventory as pkg:generic/<file name>@<sha256>, so a component search finds it and “where is it used” works for models too. A model your CI pulls from Hugging Face is there as pkg:huggingface/<org>/<name>@<revision>, at the repository whose workflow pulls it.

Where licences come from

fremforge reads a licence only from metadata already stored on fremforge, and asks no one else: nothing about your models leaves the platform. In order:

  1. The file itself: GGUF general.license, or a licence key in a safetensors file’s metadata.
  2. The model artifact the file is a layer of, when you push it to your organisation’s registry or pull it through the dependency proxy. fremforge reads the artifact’s small metadata blobs from its own store, never the weights and never the upstream registry:
    PackagingRead
    ModelPack (modctl)the config’s descriptor.licenses
    KitOps (kit)the Kitfile: a model part’s license for that part, then model.license, then package.license
    Docker Model Runner (docker model, ai/…)GGUF general.license in the model config, else the licence file in the artifact (application/vnd.docker.ai.license): Apache-2.0 and MIT are recognised by their text, any other licence by its title, such as LLAMA 3.2 COMMUNITY LICENSE AGREEMENT
    any of themthe manifest annotation org.opencontainers.image.licenses

The API returns where each licence was declared as licence_from, for example ModelPack config descriptor.licenses; the AI inventory shows it next to the licence. A licence from an image you pushed is shown only to your organisation. A model pulled before 13 October 2026 gets its licence from the artifact within an hour, when the artifact is still stored.

Models your CI pulls

The daily workflow scan reads every workflow on each repository’s default branch, and the Python and shell scripts a workflow runs from the same repository (python scripts/fetch.py, bash ci/prepare.sh). It finds:

ToolFound as
Hugging Facefrom_pretrained("org/name"), hf_hub_download(repo_id=…), snapshot_download(…), huggingface-cli download, hf download
Ollamaollama pull name:tag
Dockerdocker model pull ref
KitOpskit pull ref
ModelPackmodctl pull ref

A reference is pinned when it cannot change under your build: a Hugging Face model with a full 40-character commit sha as revision (revision="<sha>" or --revision <sha>), and an Ollama, Docker, KitOps or ModelPack model with an @sha256: digest. A branch or tag such as main or v1.0 is not pinned. Each unpinned reference is a finding, like an action pinned to a tag instead of a commit.

A model name that is not written in the file, such as from_pretrained(model_name), is listed as name set at run time. An HF_ENDPOINT set in the same file is shown, because the pull then goes to that endpoint instead of huggingface.co. A reference you remove disappears within three days.

Not found: oras pull (an ORAS artifact is a model only by its media type, which the command does not show; pulled through the dependency proxy it is classified by its manifest), and Python code outside the scripts a workflow runs.

The licence of a Hugging Face model your CI pulls by name is shown as unknown: fremforge does not contact Hugging Face, or anyone else, to look it up. A model your CI pulls as an OCI artifact through the dependency proxy gets its licence from the artifact (see Where licences come from).

ML-BOM

Download ML-BOM (CycloneDX) on the page, or GET /ai/ml-bom, returns the inventory as a CycloneDX 1.6 document. Each model file is a component of type machine-learning-model with its sha256, its declared licence (where it was declared is the fremforge:model:licence_from property), its architecture as modelCard.modelParameters.modelArchitecture when the header names one, and its format, verdict, surfaces, size, parameter count and data types as fremforge:model:* properties. Only facts read from the file are stated.

The SBOMs your CI uploads are not changed: they are your files. The ML-BOM is a separate document.

Compliance report

The compliance report has an AI inventory section: the number of model files, the files your policy blocks or would block, the files with a dismissed finding, your model scanning mode, the CI model pulls without a fixed revision, and the files per declared licence. Its CSV lists every model file. The section always covers the whole organisation, also in a report for one repository.

Audit log

EventWhen
security_policy.model_scan.updatedThe mode or the framework-pickle setting changed
security_policy.model_scan.dismissedA finding was dismissed, or a dismissal renewed
security_policy.model_scan.dismissal_revokedA dismissal was revoked
registry.pull.blocked with gate: modelA pull was refused by the model policy

A refused package upload or container pull is also in the registry access log, with the reason model_policy:<rule>. The access log does not cover Git LFS.

API

CallScope
GET /_app/api/v1/orgs/{org}/security/model-policypolicy:read
PUT /_app/api/v1/orgs/{org}/security/model-policypolicy:write
GET /_app/api/v1/orgs/{org}/security/model-findings?status=open|dismissed|allfindings:read
POST /_app/api/v1/orgs/{org}/security/model-findings/{sha256}/dismisspolicy:write
DELETE /_app/api/v1/orgs/{org}/security/model-findings/{sha256}/dismissalpolicy:write
GET /_app/api/v1/orgs/{org}/ai/models?verdict=&format=&licence=&surface=components:read
GET /_app/api/v1/orgs/{org}/ai/models/{sha256}components:read
GET /_app/api/v1/orgs/{org}/ai/ml-bomcomponents:read
GET /_app/api/v1/orgs/{org}/ai/references?tool=&unpinned=true&repository=&model=components:read

PUT replaces the whole policy: a field you leave out takes its default.

curl -X PUT \
  -H "Authorization: Bearer ${FREMFORGE_PAT}" \
  -H "Content-Type: application/json" \
  -d '{"mode":"enforce","allow_framework_pickles":false}' \
  https://frem.sh/_app/api/v1/orgs/acme/security/model-policy

Dismissing needs policy:write, not findings:write, because a dismissed file passes enforce. A CI job’s runner token never carries it.

curl -X POST \
  -H "Authorization: Bearer ${FREMFORGE_PAT}" \
  -H "Content-Type: application/json" \
  -d '{"reason":"Our own YOLO checkpoint, built in CI","expires_at":"2027-01-31"}' \
  https://frem.sh/_app/api/v1/orgs/acme/security/model-findings/<sha256>/dismiss

See the API reference for the response shapes.

What is not covered

  • Files attached to issues, pull requests and releases, and package uploads whose body holds more than the file (PyPI, NuGet and other multipart uploads). These are malware-scanned as before.
  • Models in your repositories’ ordinary Git objects. Large model files belong in Git LFS.
  • Loading or running a model, and finding a backdoor in its weights. No scanner can tell a poisoned weight from a normal one.