Skip to main content

Purser

The open-source model supply-chain control plane: policy, provenance, and enforcement for ML model artifacts — from CI to Kubernetes admission.

CI  License  Version  Python  Tests  Lint  Status  OpenSSF Best Practices

Purser is the clearance desk for models entering your environment: it gathers signals about a model artifact, evaluates them against a user-defined policy, and renders one verdict — PASS / WARN / FAIL / BLOCKED — that it enforces in CI and at Kubernetes admission. Policy can restrict models by country of origin, publisher, name, model format/type, or signer identity, and require verified provenance (require_signed). Ships as a CLI, a REST API, container images, a Helm chart, and a ValidatingAdmissionWebhook.

Scanning is one input to that verdict, not the product. Signals feeding the policy engine today:

  • a built-in static scanner — malicious code and data-exfiltration indicators across ~35 model formats, taking the best-of-breed techniques from open-source scanners (modelscan, picklescan) and extending them;
  • verified provenance — Ed25519 model signing with a trust store and revocation, plus offline Sigstore (Fulcio/Rekor) identity verification;
  • the optional deep-analysis companion (purser-deep) — pickle gadget-chain heuristics and weight tampering/steganography;
  • upstream & third-party signals — the HuggingFace Hub's own scan verdicts, refreshable loader-CVE intel (purser update-intel), an opt-in model-card/eval-attestation gate, and any feed you plug in via the purser.signals interface (an upstream safe never downgrades Purser's own verdict — see Signal sources).

The core never loads a model: nothing is deserialized or executed, all analysis is byte- and opcode-level. Format is detected by content (magic bytes), not the filename, so renaming a payload to a benign-looking extension doesn't evade the scan — a pickle disguised as model.onnx, or hidden under a README.md, is still caught.

[!TIP] New here? Start with the plain-language user guides: one for setting up scanning in GitLab, one for data scientists checking models.

[!NOTE] Pre-1.0. Published to PyPIpip install purser — with signed container images and a Helm chart on GHCR (see below). The name is pending trademark clearance (BRAND.md).

Contents

Using Purser

In Kubernetes — deploy once with the Helm chart, then scan models against the in-cluster service (rules change via helm upgrade, no rebuild). Two patterns:

helm install purser oci://ghcr.io/purser-io/charts/purser --version 0.3.0 \
  -n purser --create-namespace
KEY=$(kubectl -n purser get secret purser-auth -o jsonpath='{.data.api-key}' | base64 -d)

# 1) push a model to it — read the verdict (PASS / WARN / FAIL / BLOCKED)
curl -s -H "X-API-Key: $KEY" -F "file=@model.safetensors" \
  http://purser.purser.svc/v1/scan/upload | jq .verdict

# 2) scan a model already on a mounted store (modelStore.enabled=true)
curl -s -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{"path":"/models/vendor-drop/llama-3.safetensors"}' \
  http://purser.purser.svc/v1/scan/path | jq .verdict

A common placement is a pre-load gate: call /v1/scan/upload from CI or a model-registry hook and only promote a model whose verdict is PASS/WARN. To enforce this at deploy time in Kubernetes, enable the bundled admission webhook (admission.enabled=true), which rejects pods that reference unpinned images or unapproved model digests.

In a GitLab pipeline — run the image as a CI job; the exit code gates the pipeline (0 pass/warn · 1 findings · 2 policy-blocked · 3 error), so a bad model fails the build on its own:

scan-models:
  stage: security
  image: ghcr.io/purser-io/purser:latest
  script:
    - purser scan ./models --policy .purser/policy.yaml --format sarif --output purser.sarif
  artifacts: { when: always, paths: [purser.sarif] }

In GitHub Actions — the purser-io/purser action does the same in one step and fails the job on findings/blocked:

- uses: purser-io/purser@v0.3.0   # or pin a commit SHA
  with:
    path: ./models
    policy: .purser/policy.yaml   # optional; sarif written to purser.sarif

Use the -hf image and purser scan hf://org/model to pull + scan a HuggingFace model (add HF_TOKEN as a masked variable for private repos); add allow_failure: true while tuning the policy. Full walkthrough: docs/devsecops-gitlab.md.

What it detects

This table is the built-in static scanner's surface — one signal. See Deep analysis and Signal sources for the others.

Engine Formats Detections
Pickle opcode analysis .pkl .pt .pth .bin .ckpt .joblib .dill .pdparams Dangerous imports (os, subprocess, eval, socket, requests, …) via GLOBAL and STACK_GLOBAL resolution, multi-pickle streams, REDUCE invoked-on-load tracking, unknown-import safelist tier, unparseable/evasive pickles
PyTorch zip + legacy checkpoints, .pt2 (torch.export) All embedded pickles scanned; torch.package embedded Python source flagged
ExecuTorch .pte Flatbuffer identifier validation (extension spoofing)
Keras .h5, .keras v3 Lambda / TFOpLambda layers (marshaled-bytecode execution) and non-builtin custom layers (external code runs on load — config walked for layer classes outside the Keras/TF namespaces); works without h5py via byte heuristic
OpenVINO IR .xml (+ .bin) XXE / DOCTYPE-entity declarations, and graph references to host shared libraries (.so/.dll) or absolute paths (custom-extension code-load / host-access); XML parsed safely
TensorFlow SavedModel .pb PyFunc/EagerPyFunc (code execution), ReadFile/WriteFile/MatchingFiles and queue-based file readers (host file access) graph ops
TFLite .tflite Flex-delegate ops: FlexPyFunc (code execution), file-access kernels, full-TF attack surface; magic validation
TF.js model.json Weight-shard path traversal / remote shard references
ONNX .onnx Custom Python operator domains, external-data path traversal
safetensors .safetensors Header validation (spoofed/malformed headers used against parser bugs)
GGUF .gguf Chat-template (Jinja SSTI) injection__subclasses__, os. access, dynamic code in templates
CoreML .mlmodel .mlpackage CustomModel backend and custom-layer markers (developer-supplied native code)
skops .skops Schema types run through the pickle dangerous/safe classifier; pickle-fallback loader nodes
PaddlePaddle .pdmodel .pdparams py_func/py_layer ops (code execution); param files scanned as pickles
TorchServe .mar Bundled handler.py that TorchServe imports/executes on serve; embedded model recursed
MLflow MLmodel dir python_function flavor loader_module/bundled code/ (arbitrary code on load)
Caffe .prototxt .caffemodel type: "Python" (PythonLayer) runs arbitrary Python at inference
PMML .pmml XXE entity declarations, Extension elements with script content
Bundled Python *.py (modeling_*.py, …) AST analysis of trust_remote_code source — exec/eval, os/subprocess, sockets & HTTP clients, dynamic import, native code, marshal/pickle, base64/hex deobfuscation, os.environ harvesting; module-scope calls escalated (run on import). Dataflow/taint additionally catches payloads assembled at runtime — a dangerous callable aliased to a variable then invoked, or resolved from a decoded/char-assembled name, and deobfuscated data reaching an exec/os sink
HF config config.json, *_config.json auto_map / custom_pipelines / trust_remote_code keys that arm remote-code execution, linked to the referenced source files
NumPy .npy .npz Object-dtype arrays (embedded pickles) — payload scanned recursively
Archives .zip .tar .gz Zip-slip path traversal, zip bombs, recursive member scanning (depth-capped)
Identified for policy + exfil scan legacy GGML, Flax/msgpack, MXNet .params, XGBoost .ubj, CatBoost .cbm, TensorRT .engine/.plan/.trt, Darknet .weights, LightGBM native, Torch7 .t7, NeMo .nemo, H2O MOJO Data-only/opaque formats: named for format allowlists; full exfiltration scan applies (NeMo/MOJO archives recursed)
Exfiltration engine all files Webhook endpoints (Slack/Discord/Telegram), hard-coded IP:port, non-allowlisted URLs, cloud/API credentials (AWS, GitHub, HF, OpenAI, private keys, JWTs), embedded source with network/exec/shell idioms, base64/hex/base32/base85-encoded payloads (decoded and re-analyzed, incl. one gzip/zlib layer), single-byte XOR-obfuscated endpoints/commands (recovered by a key-invariant delta-signature search, no brute force), and UTF-16 (wide) strings that hide indicators from ASCII scans. Scans in bounded windows with a per-file finding cap; benign-host allowlist is configurable/strict-able (see env table).

How Purser compares

Where Purser sits relative to the ML model scanners: these tools are point analyzers, Purser is the control plane above them — several are things Purser can ingest rather than compete with (the Hub runs picklescan and Guardian; their verdicts arrive as signals on hf:// scans). The comparison below is on the scanning axis only. Legend: ✅ yes · ◐ partial/limited · ❌ no · ❔ not public. Best-effort assessment of publicly documented features, re-verified August 2026 — projects evolve; verify before relying on a cell.

Capability Purser picklescan Fickling ModelScan ModelAudit Commercial¹
License Apache-2.0 OSS OSS OSS OSS Commercial
Pickle opcode malware scan
Format breadth² ✅ ~35 ◐ 4 ❌ pickle only ◐ 3 ✅ 40+
Safetensors / GGUF / ONNX / TFLite
Data-exfil & secret detection³
trust_remote_code Python (AST) + auto_map
Policy engine (severity / format / publisher / name)
Country-of-origin restriction
Cryptographic signing / verified provenance⁴
CLI
REST API server
SARIF output
Docker + Kubernetes deploy
Deploy-time enforcement (CI action + K8s admission webhook)
Ingests upstream/third-party scanner verdicts (plugin signals)
Known-bad denylist (content hashes + publisher globs, offline refresh)
Loader-CVE intel (declared framework version → known load-time RCE, refreshable) ✅⁵
Live threat feeds / behavioral backdoor / dashboards ❌⁵

¹ Protect AI Guardian (built on ModelScan; Protect AI is now part of Palo Alto Networks — Prisma AIRS, acquisition completed July 2025) and HiddenLayer Model Scanner — enterprise platforms; capabilities vary and are gated behind licensing. ² ~35 distinct formats identified and policy-gated — a dedicated scanner where the format carries code/graph (pickle, Keras, ONNX, TF, GGUF, CoreML, OpenVINO, Caffe, TorchServe .mar, MLflow, …), and format-ID + exfil for data-only blobs (GGML, MXNet, GBM, TensorRT, Darknet, LightGBM, Torch7, …). For a few opaque ones (TensorRT, MXNet) it stays format-ID + exfil rather than deep graph parsing — where ModelAudit still has more per-format scanner depth (e.g. TensorRT). Pick it if that depth matters more than policy/provenance. ³ Embedded endpoints, credentials, webhooks, and encoded/compressed payloads across all file types — Purser's most distinctive engine; peers focus on code, not exfiltration strings. ⁴ Purser verifies user-signed Ed25519 signatures against a trust store that binds keys to publisher + country (with revocation/validity), and Sigstore (Fulcio/Rekor) bundles for verified external-root identity (offline). Commercial tools track provenance/lineage (AIBOM) but not user-controlled signature verification. ⁵ The loader-CVE dataset is model-scoped and refreshable like AV signatures: regenerated weekly from OSV.dev (human-reviewed PR), and end users pull updates without upgrading via purser update-intel (ModelAudit's ◐: CVE-aware checks ship with its releases, no separate refresh channel). External feeds can also plug in as signal sources. The ❌ below stays honest for live/subscription push feeds, behavioral detection, and dashboards — enterprise-platform territory.

Honest take: Purser's edge is the combination of broad format coverage, the exfiltration engine, trust_remote_code AST analysis, and a policy + verified-provenance + enforcement layer (country-of-origin, model signing, CI/admission gating) that also aggregates other analyzers' verdicts as signals — in one OSS tool. It is not a substitute for commercial platforms where you need live threat-intel subscriptions, ML-behavioral backdoor detection, dashboards, or vendor support; and ModelAudit is an excellent, more mature pure-scanner alternative if you don't need policy/provenance. All static scanners — this one included — can be evaded by novel pickle gadgets; treat a clean scan as necessary, not sufficient.

Reproducible numbers. These rows aren't just asserted — a head-to-head harness runs Purser and the OSS peers over a shared known-answer corpus and publishes detection / miss / false-positive figures: 100% detection on the known-answer set and 0% false positives across 79 benign artifacts (75 real HuggingFace models) as last measured; see benchmarks/. A weekly CI job re-measures and fails on any regression.

Policy engine

Policies are YAML. Everything is user-defined:

version: 1
name: strict
fail_on:
  severity: MEDIUM          # findings at/above this severity fail the scan
formats:
  mode: blocklist           # off | allowlist | blocklist  ("model types")
  list: [pickle, joblib, pytorch_legacy]
origin:
  mode: blocklist           # off | allowlist | blocklist
  countries: [CN, RU, KP, IR]   # ISO 3166-1 alpha-2
  unknown_origin: deny      # allow | warn | deny
publishers:
  blocked: [some-org]
  allowed: []               # non-empty => allowlist
models:                     # block/allow by model NAME (glob, case-insensitive)
  mode: blocklist           # off | allowlist | blocklist
  patterns:                 # matched against repo id (full + last component)
    - "evilcorp/*"          #   and the scan target's basename
    - "*-backdoor"
    - "known-cve-model"
denylist:                   # known-bad IOCs — any match is BLOCKED
  hashes: ["sha256:<hex>"]  # exact file-content SHA-256s
  publishers: ["evil-*"]    # publisher globs
  models: ["*/nullif-ai*"]  # repo/name globs
  files: [/feeds/bad.txt]   # external hash feeds (one digest per line),
                            #   re-read every scan — refresh like AV signatures
max_file_size_mb: 51200
rules:                      # per-rule overrides
  - id: PICKLE_UNKNOWN_IMPORT
    action: deny            # deny | warn | ignore

Country of origin is resolved in order: a verified signature (see below) → explicit --origin flag / API field → sidecar provenance.yaml next to the model → publisher lookup in the bundled database of ~70 known model publishers (purser origins), extendable via PURSER_ORIGINS=/path/origins.yaml. Unknown origins are allowed, warned, or denied per policy.

Model name matching (the models block) compares glob patterns against the model's repo id (full and last component) and the scan target's basename. For a local file/dir, tag it with --repo-id org/name so name policies apply: purser scan ./model --repo-id evilcorp/badmodel.

Known-bad denylist (the denylist block) is the AV-signature analogue for model artifacts: exact content hashes, publisher globs, and repo globs that always BLOCK. denylist.files points at external feed files (bare hex or sha256: lines, # comments) that are re-read on every scan, so an updated feed — a remounted ConfigMap, a synced IOC list — takes effect without a policy reload. Populate it from upstream unsafe verdicts, incident response, or your own curation.

Example policies live in policies/: default.yaml, strict.yaml, allowlist-us-eu.yaml, signed-only.yaml.

Verified provenance (model signing)

Without a signature, an origin/publisher claim is self-asserted and spoofable. Purser adds Ed25519 signing so origin can be a cryptographic fact: the signer signs a manifest of every file's SHA-256; verification recomputes it, requires an exact match (tamper/added-file detection), and checks the signature against a trust store that binds each signing key to a verified publisher + country.

pip install "purser[sign]"                  # or use the Docker image
purser keygen --out mykey               # Ed25519 keypair
purser sign model.safetensors --key mykey.key --key-id acme-2026
# add mykey.pub to trust_store.yaml (see policies/trust_store.example.yaml)
export PURSER_TRUST_STORE=/etc/purser/trust_store.yaml
purser verify model.safetensors         # VERIFIED / INVALID / UNTRUSTED / UNSIGNED

A verified signature outranks any claimed origin (a caller passing --origin US cannot override a signature that binds the model to CN). An invalid, untrusted, revoked, or expired signature is itself a finding. Trust -store entries support key lifecycle — revoked: true and not_before / not_after validity windows (checked against the signature's created timestamp). Set origin: { require_signed: true } in a policy (see signed-only.yaml) to reject anything not validly signed by a trusted key — this is what turns country-of-origin from a label into an enforced control.

Verified identity via Sigstore (external trust root)

The Ed25519 trust store binds key → publisher by operator assertion. For a verified external root, Purser also verifies Sigstore (Fulcio/Rekor) bundles: identity comes from a Fulcio-attested OIDC subject logged in Rekor's transparency log — the same keyless model the project uses to sign its own artifacts, and the format HuggingFace model-signing emits. Verification is offline, against a vendored trust root (no network at scan time).

pip install "purser[sigstore]"
# sign externally with cosign/sigstore (keyless OIDC), producing a bundle:
cosign sign-blob model.safetensors --bundle model.safetensors.sigstore.json
purser verify model.safetensors     # reports the verified issuer + identity (SAN)

Place the bundle beside the model (<file>.sigstore.json, or model.sigstore.json in a directory — signed over the canonical manifest). A verified identity satisfies require_signed, and an identity policy pins who may sign (issuer + SAN globs):

identity:
  mode: allowlist                 # off | allowlist | blocklist
  issuers: ["https://token.actions.githubusercontent.com"]
  identities: ["https://github.com/purser-io/*"]   # SAN globs

Refresh the vendored trust root if Sigstore rotates its roots: make sigstore-trust-root (needs purser[sigstore] + network). Signing stays external — keyless signing needs a browser/OIDC flow. Legacy HuggingFace GPG commit signatures are online-only and out of scope for offline verification.

Install and CLI usage

Purser ships on two channels — the PyPI package (with optional extras) and prebuilt container images on GHCR. Pick by how you run it:

From PyPI — one package, optional extras (pip install "purser[<extra>]"):

Extra Adds Enables
(none) core scanner, CLI, REST API scanning + policy + signal sources + admission webhook
sign cryptography Ed25519 signing / verification
sigstore sigstore verified-identity (Fulcio/Rekor) provenance
hf huggingface_hub purser scan hf://org/model (+ upstream-verdict signals)
h5 h5py deeper Keras .h5 parsing
deep (no extra deps) gadget-chain / weight-tampering analyzers in-process

Extras combine, e.g. pip install "purser[sign,hf]".

From GHCR — prebuilt, signed, multi-arch images (docker pull ghcr.io/purser-io/<image>):

Image Contents For
purser core scan service / CLI
purser-hf core + [hf] extra HuggingFace worker (Helm hf.enabled)
purser-deep deep analyzers gadget-chain / tampering companion (Helm deep.enabled)

The -hf / -deep images simply pre-bundle what you'd otherwise add as a PyPI extra — same capability, different distribution channel.

pip install "purser[sign]"    # or "purser[sign,hf]"; see the extras table above
purser scan model.pt
purser scan ./model-dir --policy policies/strict.yaml
purser scan hf://deepseek-ai/DeepSeek-R1 --policy policies/strict.yaml   # needs [hf]
purser scan model.pkl --origin CN --format json -o report.json
purser scan model.pkl --format sarif > report.sarif                     # CI integration
purser policy-check policies/strict.yaml
purser origins deepseek-ai
purser update-intel                    # refresh loader-CVE intel (no upgrade needed)

Exit codes: 0 pass/warn · 1 findings ≥ fail threshold · 2 blocked by policy (origin/format/publisher/name/signing) · 3 error.

REST API

uvicorn purser.api:app --host 0.0.0.0 --port 8080
Endpoint Purpose
GET /healthz liveness (never authenticated)
GET /metrics Prometheus metrics (unauthenticated; see Observability)
GET /v1/policy effective policy (from PURSER_POLICY)
GET /v1/origins publisher → country database
POST /v1/scan/upload multipart upload scan
POST /v1/scan/path scan a mounted path (restricted to PURSER_SCAN_ROOT)
POST /v1/scan/huggingface download + scan an HF repo (off unless enabled)
export PURSER_API_KEY=$(openssl rand -hex 32)
curl -H "X-API-Key: $PURSER_API_KEY" \
  -F "file=@model.pt" http://localhost:8080/v1/scan/upload | jq .verdict

Security-relevant environment variables

Variable Default Effect
PURSER_API_KEY (unset) If set, all /v1 endpoints require it via Authorization: Bearer <key> or X-API-Key. Comma-separated list accepted. Unset = open (trusted-network only).
PURSER_MAX_CONCURRENT_SCANS 4 In-flight scan cap; excess requests get HTTP 429.
PURSER_RATE_LIMIT_RPM 0 Per-client (API key, else IP) requests/minute; 0 disables. Over-limit → HTTP 429 with Retry-After.
PURSER_MAX_UPLOAD_MB 10240 Upload size ceiling (HTTP 413 beyond).
PURSER_MAX_SCAN_MB 4096 Bytes scanned per file for exfil; a SCAN_TRUNCATED finding is emitted if a file exceeds it.
PURSER_MAX_FINDINGS_PER_FILE 500 Cap on findings per file (bounds memory/output on adversarial input).
PURSER_EXFIL_STRICT 0 1 disables the benign-URL allowlist entirely — every embedded URL is flagged.
PURSER_EXFIL_ALLOWLIST (unset) Comma-separated hosts that replace the built-in benign-URL allowlist.
PURSER_EXFIL_ALLOWLIST_ADD (unset) Comma-separated hosts added to the built-in allowlist.
PURSER_EXFIL_XOR 1 0/false disables single-byte-XOR de-obfuscation of embedded payloads.
PURSER_ENABLE_HF 0 Must be 1/true to enable POST /v1/scan/huggingface.
PURSER_HF_ALLOWLIST (empty) Comma-separated org/ or org/repo prefixes permitted for the HF endpoint once enabled.
PURSER_ENABLE_DEEP 0 Must be 1/true to run the deep-analysis companion (see below).
PURSER_DEEP_URL (empty) Base URL of the purser-deep service. If enabled but empty, the core runs the analyzers in-process when the package is importable.
PURSER_SIGNALS 1 Disables all signal sources when falsy (0/false/no/off). Network-using built-ins run only on hub-fetched scans; the offline loader-cves source runs on every scan; third-party plugins decide their own applicability.
PURSER_SIGNAL_<NAME> 1 Per-source gate, name upper-cased with -/._ (e.g. PURSER_SIGNAL_HF_VERDICTS=0).
PURSER_SIGNAL_TIMEOUT_SECONDS 10 HTTP timeout per signal-source request.
PURSER_SIGNAL_CACHE_TTL 300 Seconds to cache hub verdict lookups for mutable refs (main); a 40-hex commit-sha revision caches for the process lifetime. 0 disables. Failures are never cached.
PURSER_ATLAS 1 0 disables MITRE ATLAS technique tags (atlas:AML.T####) appended to findings.
PURSER_LOADER_CVES (vendored) Path to a loader-CVE dataset that replaces both the vendored one and any update-intel copy — full operator control (air-gap distribution).
PURSER_INTEL_URL (project repo) URL purser update-intel fetches the dataset from — point at an internal mirror.
PURSER_INTEL_DIR ~/.purser Where update-intel installs the dataset (scans prefer it over the vendored copy).
PURSER_CARD_ATTESTATIONS 0 1/true/yes/on enables the opt-in model-card / eval-attestation gate on hub scans. Distinct from the generic per-source gate PURSER_SIGNAL_CARD_ATTESTATIONS — both must be enabled for the gate to run.
PURSER_AUTO_APPROVE 0 1 auto-populates the admission webhook's approved-digest list from verdicts: verdicts in PURSER_AUTO_APPROVE_VERDICTS (default PASS) approve each scanned file's sha256; FAIL/BLOCKED revokes.
PURSER_APPROVALS_PATH (unset) File backend for auto-approval (the exact format the webhook reads — commit/sync it into the ConfigMap via GitOps).
PURSER_APPROVALS_CONFIGMAP (unset) In-cluster backend: name of the ConfigMap to patch via the K8s API (ServiceAccount token; the Helm chart's admission.autoApprove.enabled wires this + RBAC). Also: _KEY (default approved.txt), _NAMESPACE.
PURSER_SCAN_ROOT /models Path-scan confinement root.
PURSER_METRICS_ENABLED 1 0/false disables the /metrics endpoint.
PURSER_AUDIT off stdout or syslog to emit a JSON audit record per scan.
PURSER_SYSLOG_ADDRESS /dev/log Syslog target when PURSER_AUDIT=syslog: a socket path or host:port (UDP).
PURSER_SYSLOG_FACILITY user Syslog facility name.
PURSER_SIGSTORE_TRUST_ROOT (vendored) Path to a Sigstore trusted_root.json for offline verification; defaults to the bundled root (make sigstore-trust-root to refresh).

Observability

Metrics (Prometheus). The API exposes GET /metrics in the Prometheus text format (no extra dependency — a tiny built-in registry). Series are chosen for a security dashboard:

Metric Type Labels Answers
purser_scans_total counter verdict pass/fail/blocked rate
purser_findings_total counter severity how severe
purser_findings_by_category_total counter category what kind of threat (code-execution, exfiltration, secret, steganography, gadget, …)
purser_policy_blocks_total counter reason why blocked (origin, format, publisher, name, signature)
purser_provenance_total counter status signing outcomes (verified/unsigned/invalid/revoked/…)
purser_scans_by_origin_total counter origin country of origin mix
purser_scan_files_total counter format which model formats
purser_requests_rejected_total counter reason auth / rate-limit / capacity / oversize
purser_bytes_scanned_total counter throughput
purser_scan_errors_total counter scanner/analyzer errors
purser_scans_in_progress gauge live concurrency
purser_scan_duration_seconds histogram latency (p50/p95)
purser_build_info gauge version running version
# prometheus scrape_config
- job_name: purser
  static_configs: [{ targets: ["purser:8080"] }]

Label cardinality is bounded (verdicts, severities, ~35 formats, ~20 categories, ISO country codes). /metrics is unauthenticated by design (scrapers usually are) — network-restrict it or disable with PURSER_METRICS_ENABLED=0.

Grafana. Import deploy/grafana/purser-overview.json — panels for verdict rate, threat categories, policy blocks by reason, provenance status, origin-country mix, format mix, request rejections, p95 latency, and in-flight scans.

Purser Grafana dashboard: verdict rates, findings by threat category, policy blocks by reason, provenance status, country-of-origin and format mix, API rejections, throughput, and in-flight scans

Example PromQL:

sum by (verdict)  (rate(purser_scans_total[$__rate_interval]))          # verdict rate
sum by (category) (rate(purser_findings_by_category_total[5m]))         # threats seen
sum by (reason)   (rate(purser_policy_blocks_total[5m]))                # why blocked
histogram_quantile(0.95, sum by (le) (rate(purser_scan_duration_seconds_bucket[5m])))

Audit log (syslog / SIEM). Set PURSER_AUDIT=syslog (or stdout) to emit one JSON record per scan — verdict, severity counts, origin/publisher, provenance, duration, and finding rule-ids — ready for a SIEM:

PURSER_AUDIT=syslog PURSER_SYSLOG_ADDRESS=logs.internal:514 uvicorn purser.api:app ...
# {"ts":"...","event":"model_scan","target":"model.pkl","verdict":"FAIL",
#  "severity_counts":{...},"finding_rule_ids":["PICKLE_DANGEROUS_IMPORT"], ...}

Both are driven from the central scan path, so the CLI and the API report identically.

Authentication and API keys

[!WARNING] The API is open by default (no key required) — intended for a trusted network only. Set PURSER_API_KEY before exposing it.

Set PURSER_API_KEY to require a key on every /v1 endpoint (/healthz and /metrics stay open for probes/scrapers). Keys are compared in constant time. The same key also guards the HF worker and the deep companion.

1. Generate a key

openssl rand -hex 32

2. Set it on the server — via env directly, a .env file for docker-compose, or a Kubernetes Secret (deploy/k8s/secret.yaml):

export PURSER_API_KEY=<key>
# k8s: kubectl -n purser create secret generic purser-auth \
#        --from-literal=api-key="$(openssl rand -hex 32)"

3. Send it from clients — either header works:

curl -H "X-API-Key: <key>"            ...        # or
curl -H "Authorization: Bearer <key>" ...

4. Rotate with zero downtimePURSER_API_KEY accepts a comma-separated list, and every listed key is valid at once. To rotate:

  1. Add the new key alongside the old: PURSER_API_KEY=<old>,<new> and restart/redeploy.
  2. Move clients over to <new>.
  3. Drop <old>: PURSER_API_KEY=<new> and restart/redeploy.

No request is rejected during the overlap. Use a distinct key per consumer if you want to revoke one without affecting the others (remove just that entry). Rotate keys the same way you would any secret, and store them in a secret manager — never in the repo.

Docker

Two images, so the service that handles hostile uploads carries the smallest possible dependency surface:

  • Dockerfile — slim core scanner (29 pinned deps, no huggingface_hub, no outbound HTTP-client stack). This is the default.
  • Dockerfile.hfHF worker (core + huggingface_hub, 38 deps) for the optional POST /v1/scan/huggingface download path. Run it only where you need it, ideally on a separate egress-restricted node.

Both are multi-stage builds on a digest-pinned Wolfi base (Chainguard's minimal, glibc, low-CVE distro): a build stage installs dependencies from hash-pinned lockfiles with pip install --require-hashes into a virtualenv, and the final stage copies only that venv onto a python-runtime-only Wolfi image — no pip, compilers, or build tooling ship in the running container, which runs as non-root 10001:10001. Update the base pin with make base-digest.

# Pull the published, signed, multi-arch image (also -hf and -deep variants):
docker run --rm -v $PWD/models:/models:ro -v $PWD/policies:/policies:ro \
  -e PURSER_POLICY=/policies/strict.yaml -p 8080:8080 \
  ghcr.io/purser-io/purser:0.3.0
# one-shot CLI scan:
docker run --rm -v $PWD/models:/models:ro \
  ghcr.io/purser-io/purser:0.3.0 purser scan /models

# …or build locally: make build (core) · make build-hf · make build-deep → purser:dev

Or docker compose up (see docker-compose.yml).

Deep analysis (optional companion)

purser-deep is a separate, opt-in service/container for the heavier checks the core deliberately leaves out (so they stay off the core's hostile-input path). Enable it from the core with PURSER_ENABLE_DEEP=1 + PURSER_DEEP_URL=http://purser-deep:8090 (or run in-process if the purser_deep package is importable). Its findings merge into the normal report and count toward the policy verdict.

Analyzer Finds
Gadget-chain (deep.gadget) Pickle gadget composition — indirection pivots (getattr/operator/functools), complex object graphs, deep attribute imports — that use individually-innocent pieces to evade import allowlists.
Weight tampering (deep.weights) Steganography — data hidden in the low-bit plane of float tensors (invisible to a normal scan; found by running the exfil engine over the extracted low bytes) — plus non-finite/garbage weights and shape/size mismatches. Static, from safetensors/NumPy; the model is never loaded.
make build-deep
PURSER_ENABLE_DEEP=1 PURSER_DEEP_URL=http://purser-deep:8090 \
  docker compose --profile deep up

Honest scope: these are higher-recall, higher-false-positive heuristics — a strong second opinion, not a gate on their own. They do not detect trained backdoors / data poisoning (learned behavior), which needs model-evaluation tooling and stays out of scope. CVE feeds and volumetric-DoS protection are also out of scope (use an edge WAF / scanner platform).

Signal sources (upstream intelligence)

As a control plane, Purser's verdict aggregates signals — and external intelligence about an artifact plugs into the same policy engine as the built-in scanners. Signal findings appear as signal_findings in the report, count toward the verdict, and can be tuned per rule with the normal policy rules: overrides.

Built-in: HuggingFace Hub scan verdicts (hf-verdicts). When scanning an hf:// target (CLI) or via POST /v1/scan/huggingface, Purser also reads the Hub's own per-file scan verdicts (the Hub runs picklescan, ClamAV, Protect AI Guardian, JFrog, and VirusTotal over uploads) and surfaces any upstream unsafe / caution verdict as a corroborating finding (HF_UPSTREAM_UNSAFE HIGH / HF_UPSTREAM_SUSPICIOUS MEDIUM), recording which upstream scanners flagged the file as evidence:

purser scan hf://org/model        # Purser's own analysis + the Hub's verdicts

Two rules keep this honest:

  • Upstream unsafe is a signal; upstream safe is not. Hub scanners have documented false negatives, so a clean upstream verdict never downgrades or masks what Purser's own analysis found.
  • No new network paths from the built-ins. The built-in sources only run when the artifact was fetched from a hub in the first place (the -hf path, where network is already gated); plain local scans stay fully offline. If verdicts can't be fetched, the gap is visible as a SIGNAL_UNAVAILABLE finding rather than silently missing coverage. Third-party plugins you install can add their own network paths — audit a plugin before enabling it, and pin PURSER_SIGNAL_<NAME>=0 for any you don't want.

Built-in: model-card / eval-attestation gate (card-attestations, opt-in: PURSER_CARD_ATTESTATIONS=1). For organizations that want models to document themselves: on hub scans it checks the declared model card and model-index eval results and surfaces their absence as LOW findings (CARD_MISSING, CARD_NO_EVAL_RESULTS) — a WARN-level nudge by default that policy rules: can ignore or escalate to deny (undocumented model → BLOCKED). It gates the attestation, not the behavior: declared metrics are claims, not proof of safety, and presence of a card never improves a verdict.

Built-in: loader-CVE mapping (loader-cves, offline — the first signal that runs on local scans too). No feed of malicious models exists, but framework loader CVEs are public: when an artifact declares a framework version — keras_version in a .keras/.h5 file, or transformers_version in an HF config.json — that falls in the affected range of a known load-time vulnerability, one aggregated LOW LOADER_CVE advisory is emitted per file (all matched CVEs in evidence, plus the clear_at version that clears every range). The vendored dataset (purser/data/loader_cves.yaml) is model-scoped by construction — only packages whose version an artifact can declare, only load-time CWEs (deserialization / code-exec / traversal / load-bombs; ReDoS-class noise is filtered out) — and is refreshed from OSV.dev on a weekly cadence (make loader-cves locally; a scheduled workflow opens a human-reviewed PR on drift). Operators can point PURSER_LOADER_CVES=/path/dataset.yaml at a fresher copy without upgrading. It fires only on a declared in-range version — never as blanket per-format noise — and says plainly that the loader is what's exposed, not that the artifact is malicious.

Staying updated (end users). The vendored dataset is frozen at release time; refresh it without upgrading Purser:

purser update-intel            # fetch + validate + install to ~/.purser/
purser update-intel --check    # show the active dataset's source and age

Scans never fetch — this command is the only network path, the fetched file is schema-validated before install (a bad fetch leaves the previous dataset in place), and scans prefer the updated file automatically (PURSER_LOADER_CVES env → ~/.purser/ → vendored). When the active dataset is >90 days old, table-format scans print a one-line hint. Air-gapped: point PURSER_INTEL_URL at an internal mirror, or distribute the file yourself via PURSER_LOADER_CVES (in Kubernetes, a mounted ConfigMap). Run purser update-intel on a cron/CI schedule for a fleet.

Writing your own. Third-party sources register via the purser.signals entry-point group — expose a zero-arg factory returning an object with name, available(ctx), and collect(ctx) -> list[Finding]:

# your plugin's pyproject.toml
[project.entry-points."purser.signals"]
my-feed = "my_pkg.purser_plugin:MyFeedSource"

Sources must never raise (report trouble as a finding), and may only add findings — a source cannot suppress another signal or downgrade the verdict. Disable all sources with PURSER_SIGNALS=0, or one with PURSER_SIGNAL_<NAME>=0 (e.g. PURSER_SIGNAL_HF_VERDICTS=0).

MITRE ATLAS tags. Every finding is also tagged with the MITRE ATLAS technique it evidences (atlas:AML.T0011 unsafe ML artifacts, AML.T0025 exfiltration, AML.T0018 backdoored model, AML.T0010.003 supply-chain: model) from a vendored mapping — enrichment for SARIF/SOC pipelines, not a signature source. PURSER_ATLAS=0 turns it off.

Supply chain (of Purser itself)

A security tool should be verifiable. make targets and .gitlab-ci.yml cover:

Concern How
Reproducible deps make lock writes hash-pinned requirements*.lock; images use --require-hashes; make lock-verify is a CI gate that fails on drift
SBOM make sbom emits deterministic CycloneDX 1.5 (sbom/*.cdx.json) from the lockfiles — no build timestamp, so it's reproducible and diffable
Dependency isolation HF tree split into a separate image (above)
Signed images CI signs with cosign keyless (Fulcio/Rekor) and attaches the SBOM as a CycloneDX attestation on release tags; verify with cosign verify / make verify-sig
Vuln scanning make scan runs trivy against the image (HIGH/CRITICAL gate)

Kubernetes

Recommended: the Helm chart (deploy/helm/purser/) — production-ready, with hardened securityContext, HPA/PDB, ServiceMonitor, NetworkPolicy, a values-driven policy ConfigMap, generated/retained API-key Secret, and optional HF-worker + deep-companion subcharts (auto-wired):

# published OCI chart (defaults to the ghcr.io/purser-io/purser images)…
helm install purser oci://ghcr.io/purser-io/charts/purser --version 0.3.0 \
  -n purser --create-namespace
# …or from a source checkout: helm install purser deploy/helm/purser ...
helm test purser -n purser

See the chart README and values.yaml.

Or plain kustomize manifests under deploy/k8s/ for a kubectl-only setup:

kubectl apply -k deploy/k8s

Both run non-root with a read-only root filesystem, no privilege escalation, and /healthz probes; policy is a mounted ConfigMap (change it without rebuilding); mount a model-store PVC at /models for POST /v1/scan/path.

Deploy-time enforcement (admission.enabled=true). An optional ValidatingAdmissionWebhook closes the scan→deploy TOCTOU gap: scanning proves a model was safe when scanned; the webhook enforces at admission that every container image is pinned by @sha256: digest and that any model a workload declares (annotation purser.io/models) is on the approved-digest list — the SHA-256s of models that passed a scan. Opt-in per namespace/pod and fail-closed by default; see the chart README.

Closing the loop (admission.autoApprove.enabled=true). The approved list can populate itself from verdicts instead of being operator-managed: a PASS at any scan endpoint approves each scanned file's digest into the webhook's ConfigMap (narrow RBAC — get/patch on that one ConfigMap), and a later FAIL/BLOCKED on the same artifact revokes it. Scan → approve → admit, with no manual hop; every action is recorded in the report's metadata.approvals and the audit log. Outside Kubernetes the same mechanism writes a file (PURSER_APPROVALS_PATH) you can GitOps into the ConfigMap.

Security model

  • Models are never loaded: pickle streams are analyzed with pickletools.genops, archives are size/ratio-checked before reading, H5/protobuf/GGUF are inspected at byte level.
  • The scanning service is designed to handle hostile files: zip-bomb and path-traversal guards, upload size caps, bounded windowed scanning with a per-file finding cap, scan-root confinement for path scans, non-root read-only container.
  • It is also designed against hostile clients: optional API-key auth on all /v1 endpoints, a concurrency cap (HTTP 429 when full), and an off-by-default, allowlist-scoped HuggingFace download endpoint.
  • Provenance can be cryptographically verified (Ed25519 signing + trust store); a require_signed policy makes country-of-origin an enforced control.
  • Signals are add-only and untrusted. A signal source can only add findings — the plugin context deliberately excludes the in-progress report, so no signal can suppress or downgrade another finding. Signal responses are parsed as JSON, never executed; the built-ins make network calls only on hub-fetched scans; third-party plugins are operator-installed code (audit before enabling).
  • Deploy-time enforcement fails closed. The admission webhook defaults to failurePolicy: Fail and is opt-in per namespace — an outage blocks opted-in deploys rather than waving them through.
  • A finding severity model (INFO → CRITICAL) feeds the policy verdict: PASS / WARN / FAIL / BLOCKED / ERROR.
  • Honest limits: static scanning cannot prove safety (novel pickle gadgets, weight/backdoor poisoning are out of scope), so use it as one layer of defense-in-depth.

Development

uv venv && uv pip install -e ".[dev]"
pytest

Roadmap and security posture

  • CHANGELOG.md — released versions and what each one shipped.
  • ROADMAP.md — what's next and why (foundation readiness: CNCF Landscape entry — the OpenSSF Best Practices badge is already earned; the purser-eval companion; Wolfi base auto-refresh). The external-PKI/ Sigstore trust root, per-format scanner depth, the adversarial evasion benchmark, the pluggable signal sources (purser.signals), and the Kubernetes admission webhook have shipped.
  • SECURITY.md — disclosure policy + SME security evaluation of the code and container images (threat model, hardening, residual risk).
  • docs/openssf-best-practices.md — OpenSSF Best Practices passing-criteria self-assessment (each mapped to evidence).

Contributing

Issues and merge/pull requests are welcome. Please run ruff check and pytest before submitting, keep changes covered by tests, and report security issues privately per SECURITY.md (not via a public issue).

License

Licensed under the Apache License 2.0 — Copyright © 2026 The Purser Authors. Bundled third-party dependencies and their licenses are listed in THIRD_PARTY_LICENSES.md (auto-generated from the SBOM via make licenses; all permissive, no copyleft beyond MPL-2.0/certifi).

Product names, logos, and brands referenced here (e.g. ModelScan, picklescan, Fickling, ModelAudit, Protect AI Guardian, HiddenLayer, Kubernetes, GitLab, Hugging Face) are trademarks of their respective owners; see TRADEMARKS.md for use of the Purser name and logo.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

purser-0.3.0.tar.gz (580.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

purser-0.3.0-py3-none-any.whl (140.2 kB view details)

Uploaded Python 3

File details

Details for the file purser-0.3.0.tar.gz.

File metadata

  • Download URL: purser-0.3.0.tar.gz
  • Upload date:
  • Size: 580.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for purser-0.3.0.tar.gz
Algorithm Hash digest
SHA256 1184a957964d958ec3709d56f188c8a24ddd6e09d0e95c146e5944eed15af8a3
MD5 178a1adb74a8e290c3fdbc3dd4726e2e
BLAKE2b-256 b09c6ef1e65b21aa0acbae2d7102645e8789fb646da7f49156990c3a74b6fb0b

See more details on using hashes here.

Provenance

The following attestation bundles were made for purser-0.3.0.tar.gz:

Publisher: release.yml on purser-io/purser

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file purser-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: purser-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 140.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for purser-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 c99e414ad73dc8ac945f5359ad4cfb8adc16e997fb745e5515294260d0ec338a
MD5 621c7cd591c49d55bbd62f720b0ac31d
BLAKE2b-256 addd6dc1f45b33127e95abc131c3fe3751edc6625076fdead82e685ccc47ea40

See more details on using hashes here.

Provenance

The following attestation bundles were made for purser-0.3.0-py3-none-any.whl:

Publisher: release.yml on purser-io/purser

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 files

0.2.1

2 files

0.2.0

2 files

0.1.3

2 files

0.1.2

2 files

0.1.1

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page