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
| Surface | When it is scanned | What enforce does |
|---|---|---|
| Git LFS | On upload | Refuses the upload with 422 |
| Packages (generic, and every type whose upload body is the file) | On upload | Refuses the upload with 422 |
Container registry (ModelPack, KitOps, Docker ai/ and model files inside ordinary images) | After the push | Refuses a pull that would serve the layer with 403 |
| Dependency proxy (container images) | When it is first pulled | Refuses later pulls of the layer with 403 |
| Actions artifacts | After the run finishes | Nothing. 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
| Format | What is checked |
|---|---|
Pickle, joblib, PyTorch (.pt, .pth, .bin, legacy and zip), NumPy object arrays | Every 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 |
| safetensors | The header: lengths, data types and offsets, and that the file cannot also be opened as a zip or a pickle |
| GGUF | The metadata, the chat template (against a safe subset of Jinja), and the same zip and pickle checks |
| ONNX | Custom 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.systemorbuiltins.eval), or a KerasLambdalayer. - 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.
| Mode | What happens |
|---|---|
| Shadow (default) | Every file is scanned and the files the policy would block are listed as findings. Nothing is refused. |
| Enforce | As shadow, and the files with an open finding are refused, as listed in Where model files are found. |
| Off | Files 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.
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:
- The file itself: GGUF
general.license, or a licence key in a safetensors file’s metadata. - 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:
Packaging Read ModelPack ( modctl)the config’s descriptor.licensesKitOps ( kit)the Kitfile: a model part’s licensefor that part, thenmodel.license, thenpackage.licenseDocker Model Runner ( docker model,ai/…)GGUF general.licensein 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 asLLAMA 3.2 COMMUNITY LICENSE AGREEMENTany of them the 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:
| Tool | Found as |
|---|---|
| Hugging Face | from_pretrained("org/name"), hf_hub_download(repo_id=…), snapshot_download(…), huggingface-cli download, hf download |
| Ollama | ollama pull name:tag |
| Docker | docker model pull ref |
| KitOps | kit pull ref |
| ModelPack | modctl 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
| Event | When |
|---|---|
security_policy.model_scan.updated | The mode or the framework-pickle setting changed |
security_policy.model_scan.dismissed | A finding was dismissed, or a dismissal renewed |
security_policy.model_scan.dismissal_revoked | A dismissal was revoked |
registry.pull.blocked with gate: model | A 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
| Call | Scope |
|---|---|
GET /_app/api/v1/orgs/{org}/security/model-policy | policy:read |
PUT /_app/api/v1/orgs/{org}/security/model-policy | policy:write |
GET /_app/api/v1/orgs/{org}/security/model-findings?status=open|dismissed|all | findings:read |
POST /_app/api/v1/orgs/{org}/security/model-findings/{sha256}/dismiss | policy:write |
DELETE /_app/api/v1/orgs/{org}/security/model-findings/{sha256}/dismissal | policy: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-bom | components: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-policyDismissing 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>/dismissSee 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.