assay
Offline-first, single-binary scanner for ML model artifacts - safetensors, GGUF and PyTorch pickle. Assay the weights before you trust them.
Assay the weights before you trust them.
assay is an offline-first, single-binary scanner for ML model artifacts (safetensors, GGUF, PyTorch pickle). It answers two questions about a model you just downloaded:
- Is this file what it claims to be? - provenance & integrity
- Does loading it put my machine at risk? - format-level safety
A downloaded model is a multi-gigabyte opaque blob that people execute with total trust. We would never do that with a random .exe. assay applies the same supply-chain hygiene to model weights.
The name comes from metallurgy: an assay tests the purity and composition of a metal. A model is literally weights - so assay tests whether those weights are pure (no contaminant) and authentic (real provenance). Written in Rust, no runtime dependencies.
Two-phase architecture
Phase 1 - provenance & integrity
Default, always on. Boring, solid, high-confidence, deterministic verdicts:
- Format detection & structural parsing (
safetensors/GGUF/ pickle) - Pickle / arbitrary-code-execution risk flagging
safetensorsheader & offset validationGGUFmetadata sanity + embedded-template flagging- Deterministic content hashing (per-tensor + manifest)
- Signature verification (detached ed25519 + model-transparency manifest; full Sigstore/cosign chain reported as unverified, not trusted)
- Human + JSON reports, CI-friendly exit codes
Phase 2 - weight inspection
Opt-in via --deep. Inspects the weights themselves. There is no external ground truth here, so Phase 2 emits signals with scores and severities, never verdicts - a high score means "anomalous, worth a human look", never "malicious". It never loads or executes the model; tensors are read cold and streamed (mmap, online moments) so peak RAM stays well under model size.
| Sub-check | Signal IDs | What it does |
|---|---|---|
| 2a per-tensor stats | WEIGHT_NAN_INF | NaN/Inf integrity, mean/std, L2/RMS, excess kurtosis, sparsity, 6σ outlier mass |
| 2b layer profile | WEIGHT_OUTLIER_LAYER | robust median/MAD anomaly detection across layers, terminal sparkline + optional 1D SVG |
| 2c secret/string scan | EMBEDDED_SECRET, SUSPICIOUS_URL | scans metadata & sibling configs (experimental tensor-entropy behind a flag) |
| 2d architectural fingerprint | ARCH_DETECTED, ARCH_MISMATCH | infers the model architecture and flags mismatches |
compare SUBJECT BASELINE - differential weight analysis
Weight analysis is most honest as a diff against a known-good reference, not a judgment of a model in isolation: a normally-trained transformer is naturally non-uniform across layers, so the standalone profile can flag legitimate peaks. compare makes the baseline the zero line - identical models are silent, a uniform fine-tune shows broad even drift (quiet), and a localized tamper shows a single concentrated spike (flagged). It guards against cross-architecture comparison (ARCH_MISMATCH, override with --force) and emits STRUCTURAL_DIVERGENCE for added/removed/reshaped tensors, LAYER_DRIFT_OUTLIER / TENSOR_DRIFT for concentrated drift, and IDENTICAL when drift is ~0 everywhere.
GGUF note: legacy quants (Q4_0/Q4_1/Q5_0/Q5_1/Q8_0) and F32/F16/BF16 are dequantized for real stats; k-quant / IQ tensors are reported STATS_DEFERRED_QUANTIZED (structural info only) rather than computing garbage on raw block bytes.
Installation
# from crates.io
cargo install assay
# or grab prebuilt static binaries from GitHub releases (no runtime deps, single file)cargo install assay is the primary install path today. Prebuilt release binaries are published on the releases page as they are cut.
Usage
# scan a single file
assay scan model.safetensors
# scan a whole model directory (HF-style repo)
assay scan ./Qwen2.5-0.5B-Instruct/
# CI mode: machine-readable, non-zero exit on findings
assay scan ./model/ --json --fail-on high
# verify a signature / provenance bundle alongside the weights
assay verify ./model/ --bundle model.sig
# Phase 2: inspect the weights (signals, not verdicts)
assay scan ./model/ --deep --profile # per-tensor stats + layer sparkline
assay scan ./model/ --deep --svg profile.svg # write the 1D layer-profile chart
assay scan ./model/ --deep --mad-k 5.0 --json # tune the robust anomaly threshold
# compare: how a model differs from a known-good baseline (the honest profile)
assay compare ./model-suspect/ ./model-known-good/ # drift profile + spikes
assay compare ./subject/ ./baseline/ --svg drift.svg --json
assay compare ./a/ ./b/ --force # across architectures (unreliable)Flags
| Flag | Description |
|---|---|
--deep (alias --stats) | Enable Phase 2 weight analysis |
--profile | Print the per-layer sparkline |
--svg <path> | Write a faithful 1D layer-profile chart |
--mad-k <f64> | Anomaly threshold in MADs (default 5.0) |
--fail-on <sev> | Severity that triggers a non-zero exit |
--bundle <path> | Signature / provenance bundle to verify |
--force | Allow compare across architectures (unreliable) |
--no-progress | Disable the real-time stderr progress (auto-off when not a TTY) |
--color auto|always|never | Control colorization |
Exit codes
| Code | Meaning |
|---|---|
0 | clean - no findings at or above the threshold |
1 | findings at/above --fail-on severity |
2 | unreadable / malformed artifact (parse failure) |
>2 | internal error |
Example output
scan --deep --profile on a real gpt2 repo (Phase 1 + Phase 2):
$ assay scan ./models/gpt2 --deep --profile
[1/2] ./models/gpt2/model.safetensors CLEAN 3 finding(s) (22.90s)
[2/2] ./models/gpt2/pytorch_model.bin UNTRUSTED 3 finding(s) (1ms)
✓ scanned 2 artifact(s) - 1 clean, 1 untrusted, 1.0 GiB in 22.91s
./models/gpt2/model.safetensors [safetensors] -> CLEAN
manifest: blake3:d4ceed607f7040ba84b91eadef010d98079f9d9d85ffd6faf13d77ce958eccdf
signature: unsigned
[low] WEIGHT_OUTLIER_LAYER: layer 3 is anomalous on mean_kurtosis (6.0 MADs from the cross-layer median)
[info] ARCH_DETECTED: structural fingerprint: gpt2 (gpt2)
./models/gpt2/pytorch_model.bin [pickle] -> UNTRUSTED
[high] PICKLE_RCE_RISK: pickle artifact can execute code at load time
[info] SAFE_ALTERNATIVE_AVAILABLE: a safetensors artifact is present in the same repo; prefer itThe standalone profile flags layers 3 and 11 - but on a model in isolation you can't tell a legitimate peak from an injected one. That's exactly why compare exists: against a tampered copy, only the tampered layer spikes, and the innocent peaks stay silent.
What Phase 1 checks
- Format detection - identifies each artifact and refuses to guess. A repo mixing
safetensorsand pickle is itself a signal. - Pickle / RCE risk (highest priority) - flags every pickle artifact as untrusted-by-default, runs an opcode-level scan for dangerous patterns (
GLOBAL,REDUCE, imports ofos/subprocess/builtins), and tells you whether a cleansafetensorsequivalent exists in the same repo. - safetensors structural validation - parses the header and length prefix, validates every
data_offsets [begin, end](in-bounds, non-overlapping, no gaps), and rejects dtype/shape mismatches. - GGUF metadata sanity - validates magic + version, tensor count and KV block, checks offsets, and flags embedded Jinja2 chat templates for human review.
- Deterministic hashing - per-tensor digest + manifest hash stable across re-containerization (renaming/repacking doesn't change identity).
- Signature / provenance verification - verifies a Sigstore bundle / cosign signature / model-transparency manifest against the computed hashes. Reports: signed / unsigned / signature-mismatch.
Design principles
- Offline-first. No network calls during a scan. Signature roots are bundled or supplied explicitly.
- Single static binary, no runtime deps. Drop it into a CI image or an air-gapped box and run.
- Honest confidence. Every finding carries a severity. Phase 1 is high-confidence by design; it never pretends to detect backdoors it can't.