AID-H-003.009: Model Artifact Static Malware & Unsafe-Serialization Scanning
Pillars: model, infra
Lifecycle: building, validation, operation
Scope boundary
Owns format-aware static malware, unsafe-serialization, archive, and loader-hazard scanning of model artifacts before any model loader executes them. Its clean verdict is admission evidence, not proof of safe execution.
AID-H-003.001: AID-H-003.009 scans model artifact formats and serialized objects; AID-H-003.001 evaluates third-party software dependencies and packages. Model-file loader hazards require dedicated format semantics beyond package SCA.
AID-H-003.006: AID-H-003.009 determines whether the artifact contains known unsafe serialization or malware indicators; AID-H-003.006 verifies the artifact's declared composition and provenance. A trusted attestation does not make unsafe bytes executable, and a clean scan does not establish provenance.
AID-I-001.005: AID-H-003.009 performs static analysis before any loader runs; AID-I-001.005 detonates and observes first load inside an isolated sandbox. Dynamic loading can reveal behavior that static scanners miss, while sandbox execution must not replace pre-load rejection.
AID-H-025.001: AID-H-003.009 analyzes serialized model and adapter artifacts; AID-H-025.001 analyzes generated code and execution requests for dangerous constructs. Both are pre-execution gates, but they operate on different artifact languages and loaders.
AID-H-002.009: AID-H-003.009 statically scans models, checkpoints, and adapters for unsafe serialization and embedded code; AID-H-002.009 admits untrusted dataset and structured-data artifacts against parser-specific features. Route each object by its actual format and loader; a model-artifact scan does not make a dataset parser safe, and parser admission does not approve model loading.
Warning
Static Scanning Is Necessary but Not Sufficient
Pickle and model-artifact scanners are a required admission layer, but they are not a proof of safe execution. 2025 scanner-bypass disclosures against pickle/model scanners, including CVE-2025-1716, CVE-2025-1944, CVE-2025-1945, CVE-2025-10155 through CVE-2025-10157, and nullifAI-style bypass techniques, show that malformed pickle opcodes, archive tricks, and unsupported loader paths can evade static tools. Treat a clean scan as one input to promotion only: require tensor-only formats where possible, signed provenance, offline loading, dynamic first-load sandboxing for pickle-like artifacts, and a rapid scanner-update process.
Implementation guidance
- AID-H-003.009-G001: Run static model-file scanners before any model import, mirror promotion, registry acceptance, or deployment, and produce a signed admission verdict that release gates can consume.
AID-H-003.009-G001
Delivery level: production artifact-admission job
Scan one deployment-controlled byte snapshot before any deserializer or framework loader sees it. The scanner, artifact digest, report, and admitted copy must all refer to those same bytes; scanning a mutable download path and hashing it later creates a verification-to-use race.
Step 1: Verify the scanner profile and capture one immutable snapshot
# File: supply_chain/model_static_admission.py
from __future__ import annotations
import argparse
import hashlib
import json
import math
import os
import re
import shutil
import stat
import subprocess
import tempfile
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
POLICY_KEY = Path("/opt/aidefend/trust/model-artifact-profile.pub")
SHA256 = re.compile(r"[0-9a-f]{64}")
def strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
value: dict[str, Any] = {}
for key, item in pairs:
if key in value:
raise ValueError(f"duplicate JSON key: {key}")
value[key] = item
return value
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def run(args: list[str], timeout_seconds: float) -> subprocess.CompletedProcess[str]:
return subprocess.run(
args, check=False, capture_output=True, text=True, timeout=timeout_seconds
)
def load_policy(
path: Path, bundle: Path, timeout_seconds: float
) -> tuple[dict[str, Any], str]:
payload = path.read_bytes()
with tempfile.NamedTemporaryFile(prefix="model-profile-", delete=False) as handle:
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
snapshot = Path(handle.name)
try:
subprocess.run(
[
"cosign", "verify-blob", "--key", str(POLICY_KEY),
"--bundle", str(bundle), str(snapshot),
],
check=True, capture_output=True, text=True, timeout=timeout_seconds,
)
finally:
snapshot.unlink(missing_ok=True)
profile = json.loads(
payload.decode("utf-8", errors="strict"),
object_pairs_hook=strict_object,
parse_constant=lambda value: (_ for _ in ()).throw(ValueError(f"non-finite JSON number: {value}")),
)
fields = {
"schema_version", "policy_version", "allowed_extensions",
"pickle_like_extensions", "max_artifact_bytes",
}
if not isinstance(profile, dict) or set(profile) != fields:
raise ValueError("artifact profile schema mismatch")
if profile["schema_version"] != "aidefend.model-artifact-profile.v1":
raise ValueError("unsupported artifact profile")
if not isinstance(profile["policy_version"], str) or not profile["policy_version"]:
raise ValueError("policy_version is required")
for field in ("allowed_extensions", "pickle_like_extensions"):
values = profile[field]
if (
not isinstance(values, list) or not values
or len(values) != len(set(values))
or any(not isinstance(value, str) or not value.startswith(".") or value != value.lower() for value in values)
):
raise ValueError(f"invalid {field}")
if not set(profile["pickle_like_extensions"]).issubset(profile["allowed_extensions"]):
raise ValueError("pickle-like extensions must be allowed only through this scan path")
if type(profile["max_artifact_bytes"]) is not int or profile["max_artifact_bytes"] < 1:
raise ValueError("max_artifact_bytes must be positive")
profile["allowed_extensions"] = set(profile["allowed_extensions"])
profile["pickle_like_extensions"] = set(profile["pickle_like_extensions"])
return profile, hashlib.sha256(payload).hexdigest()
def capture_snapshot(source: Path, destination: Path, maximum_bytes: int) -> str:
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
descriptor = os.open(source, flags)
try:
opened = os.fstat(descriptor)
if not stat.S_ISREG(opened.st_mode) or opened.st_size < 1 or opened.st_size > maximum_bytes:
raise ValueError("artifact must be one non-empty regular file within the signed size cap")
digest = hashlib.sha256()
with os.fdopen(descriptor, "rb", closefd=False) as source_handle, destination.open("xb") as target:
remaining = opened.st_size
while remaining:
chunk = source_handle.read(min(1024 * 1024, remaining))
if not chunk:
raise RuntimeError("artifact ended while snapshot was being captured")
target.write(chunk)
digest.update(chunk)
remaining -= len(chunk)
if source_handle.read(1):
raise RuntimeError("artifact grew while snapshot was being captured")
target.flush()
os.fsync(target.fileno())
after = os.fstat(descriptor)
if (opened.st_dev, opened.st_ino, opened.st_size, opened.st_mtime_ns) != (
after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns
):
raise RuntimeError("artifact changed while snapshot was being captured")
os.chmod(destination, 0o400)
return digest.hexdigest()
finally:
os.close(descriptor)
@dataclass(frozen=True)
class StaticVerdict:
schema_version: str
artifact_sha256: str
artifact_size: int
extension: str
policy_version: str
policy_sha256: str
modelscan_version: str
fickling_version: str | None
decision: str
reasons: list[str]
observed_at: str
def admit(
source: Path, admitted_path: Path, report_path: Path, profile: dict[str, Any],
policy_sha256: str, timeout_seconds: float, report_limit: int,
) -> StaticVerdict:
extension = source.suffix.lower()
if extension not in profile["allowed_extensions"]:
raise PermissionError(f"extension is not allowed: {extension}")
admitted_path.parent.mkdir(parents=True, exist_ok=True)
if admitted_path.exists():
raise FileExistsError("admitted output already exists")
with tempfile.TemporaryDirectory(prefix="model-admission-", dir=admitted_path.parent) as directory:
snapshot = Path(directory) / ("artifact" + extension)
artifact_sha256 = capture_snapshot(source, snapshot, profile["max_artifact_bytes"])
modelscan_version_result = run(["modelscan", "--version"], timeout_seconds)
if modelscan_version_result.returncode != 0:
raise RuntimeError("cannot identify ModelScan release")
modelscan_report = Path(directory) / "modelscan.json"
modelscan = run(
["modelscan", "-p", str(snapshot), "-r", "json", "-o", str(modelscan_report)],
timeout_seconds,
)
reasons = []
if modelscan.returncode != 0:
reasons.append(f"modelscan_blocked_or_failed:{modelscan.returncode}")
fickling_version = None
if extension in profile["pickle_like_extensions"]:
version = run(["fickling", "--version"], timeout_seconds)
if version.returncode != 0:
raise RuntimeError("cannot identify Fickling release")
fickling_version = (version.stdout or version.stderr).strip()[:report_limit]
fickling = run(["fickling", "--check-safety", "-p", str(snapshot)], timeout_seconds)
if fickling.returncode != 0:
reasons.append(f"fickling_blocked_or_failed:{fickling.returncode}")
verdict = StaticVerdict(
schema_version="aidefend.model-static-admission.v1",
artifact_sha256=artifact_sha256,
artifact_size=snapshot.stat().st_size,
extension=extension,
policy_version=profile["policy_version"],
policy_sha256=policy_sha256,
modelscan_version=(modelscan_version_result.stdout or modelscan_version_result.stderr).strip()[:report_limit],
fickling_version=fickling_version,
decision="BLOCK" if reasons else "ALLOW",
reasons=reasons,
observed_at=datetime.now(timezone.utc).isoformat(),
)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(asdict(verdict), indent=2, sort_keys=True) + "\n", encoding="utf-8")
if reasons:
return verdict
with snapshot.open("rb") as source_handle, admitted_path.open("xb") as target:
shutil.copyfileobj(source_handle, target, length=1024 * 1024)
target.flush()
os.fsync(target.fileno())
os.chmod(admitted_path, 0o400)
if sha256_file(admitted_path) != artifact_sha256:
admitted_path.unlink(missing_ok=True)
raise RuntimeError("admitted copy differs from scanned snapshot")
return verdict
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--artifact", type=Path, required=True)
parser.add_argument("--profile", type=Path, required=True)
parser.add_argument("--profile-bundle", type=Path, required=True)
parser.add_argument("--admitted-artifact", type=Path, required=True)
parser.add_argument("--report", type=Path, required=True)
parser.add_argument("--timeout-seconds", type=float, required=True)
parser.add_argument("--report-max-characters", type=int, required=True)
args = parser.parse_args()
if not math.isfinite(args.timeout_seconds) or args.timeout_seconds <= 0 or args.report_max_characters < 1:
raise ValueError("runtime limits are invalid")
profile, policy_sha256 = load_policy(args.profile, args.profile_bundle, args.timeout_seconds)
verdict = admit(
args.artifact, args.admitted_artifact, args.report, profile,
policy_sha256, args.timeout_seconds, args.report_max_characters,
)
return 0 if verdict.decision == "ALLOW" else 1
if __name__ == "__main__":
raise SystemExit(main())Step 2: Sign and hand off the decision
set -euo pipefail
python supply_chain/model_static_admission.py --artifact downloads/candidate.bin --profile policy/model-artifact-profile.json --profile-bundle policy/model-artifact-profile.sigstore.json --admitted-artifact quarantine/admitted/candidate.bin --report evidence/model-static-admission.json --timeout-seconds "$MODEL_SCANNER_TIMEOUT_SECONDS" --report-max-characters "$MODEL_SCANNER_REPORT_MAX_CHARACTERS"
cosign sign-blob --yes --key "$ADMISSION_SIGNING_KEY_URI" --bundle evidence/model-static-admission.sigstore.json evidence/model-static-admission.jsonThe loader receives only quarantine/admitted/candidate.bin together with its recorded digest. Unsupported formats, oversize or changing input, scanner failure, unsafe findings, missing scanner version, copy drift, or evidence-signing failure blocks load and promotion. Pickle-derived or custom-loader artifacts still require the separate dynamic sandbox verdict owned by AID-I-001.005.
Open-source tools
- ModelScan
- Fickling
- picklescan
- YARA
Commercial / hosted tools
- Prisma AIRS AI Model Security
- JFrog Advanced Security
- ReversingLabs Spectra Assure
- HiddenLayer Model Scanner
Threat mappings
MITRE ATLAS
- AML.T0011 User Execution
- AML.T0010 AI Supply Chain Compromise
- AML.T0010.003 AI Supply Chain Compromise: Model
- AML.T0011.000 User Execution: Unsafe AI Artifacts
- AML.T0112.001 Machine Compromise: AI Artifacts
- AML.T0119 Exploit Automated Artifact Processing Pipeline (unsafe-serialization scanning blocks artifacts that would execute during automated import or conversion)
MAESTRO
- Supply Chain Attacks (Cross-Layer)
- Supply Chain Attacks (L3)
- Compromised Framework Components (L3)
OWASP LLM Top 10 2026
- LLM04:2026 Supply Chain
OWASP ML Top 10 2023
- ML06:2023 AI Supply Chain Attacks
OWASP Top 10 for Agentic Applications 2026
- ASI04:2026 Agentic Supply Chain Vulnerabilities
- ASI05:2026 Unexpected Code Execution (RCE)
NIST Adversarial Machine Learning 2025
- NISTAML.051 Model Poisoning (Supply Chain)
Cisco Integrated AI Security and Safety Framework
- AISubtech-9.2.1 Obfuscation Vulnerabilities (Inspects concealed executable model-serialization content)
- AISubtech-9.3.1 Malicious Package / Tool Injection (Rejects executable malicious model packages before registry admission)
- AITech-9.2 Detection Evasion (Inspects concealed executable model-serialization content)
Google Secure AI Framework 2.0 - Risks
- MDT: Model Deployment Tampering
Databricks AI Security Framework 3.0
- Model 7.3: ML Supply chain vulnerabilities
- Platform 12.5: Poor security in the software development lifecycle