Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# AIsbom: The Supply Chain for Artificial Intelligence

[![PyPI version](https://img.shields.io/pypi/v/aisbom-cli.svg)](https://pypi.org/project/aisbom-cli/)
[![Downloads](https://img.shields.io/pypi/dm/aisbom-cli.svg)](https://pypistats.org/packages/aisbom-cli)
[![GitHub Marketplace](https://img.shields.io/badge/GitHub-Marketplace-2088FF?logo=github)](https://github.com/marketplace/actions/aisbom-security-scanner)
![License](https://img.shields.io/badge/license-Apache%202.0-blue)
![Python](https://img.shields.io/badge/python-3.11%2B-blue)
Expand Down Expand Up @@ -534,6 +535,7 @@ AI models aren't just text files — they're executable programs and IP assets.

- **The security risk.** PyTorch (`.pt`) files are Zip archives containing Pickle bytecode. A malicious model executes arbitrary code (RCE) the moment it's loaded.
- **The legal risk.** A developer might download a "non-commercial" model (e.g., CC-BY-NC) and ship it to production. The license is embedded in the binary header — standard SBOM tools miss it entirely.
- **The regulatory risk.** What ships inside a model is increasingly something you have to document. The **EU AI Act** puts general-purpose AI models under **Article 53**, with technical documentation described in **Annex XI**. The **EU Cyber Resilience Act** phases in vulnerability reporting from 11 September 2026 and an SBOM requirement in December 2027. **FDA §524B** treats a missing SBOM as a refuse-to-accept criterion for cyber devices, and has required VEX alongside it since March 2026. AIsbom produces the artifacts those filings are assembled from — an AIBOM (ML-BOM) in CycloneDX or SPDX form, plus VEX statements — the evidence MLSecOps and product-security teams get asked for. It surfaces that evidence; it doesn't assess or certify compliance, and that assessment stays with you as the provider.
- **The solution.** AIsbom looks *inside*. We decompile bytecode and parse binary metadata headers without loading the heavy weights into memory.

---
Expand Down
48 changes: 47 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,9 +1,55 @@
[tool.poetry]
name = "aisbom-cli"
version = "1.5.0"
description = "An AI Supply Chain security tool that that detects Pickle bombs and generates CycloneDX SBOMs for Machine Learning models."
description = "Static security scanner for ML model files — detects pickle bombs, Keras Lambda RCE and GGUF template injection, and generates CycloneDX / SPDX AI-BOMs (AIBOM) as EU AI Act, CRA and FDA §524B evidence."
authors = ["Ajoy L <lab700xdev@gmail.com>"]
readme = "README.md"
license = "Apache-2.0"
# Discovery metadata: PyPI renders keywords as facets and classifiers as
# sidebar filters, so the high-intent terms buyers actually search for
# ("aibom", "eu-ai-act", "mlsecops") have to appear here, not only in the
# README. Additive only — none of this affects resolution or runtime.
keywords = [
"sbom",
"aibom",
"ai-bom",
"mlbom",
"ml-bom",
"cyclonedx",
"spdx",
"vex",
"ai-security",
"machine-learning-security",
"mlsecops",
"model-scanning",
"malware-detection",
"pickle",
"supply-chain-security",
"eu-ai-act",
"cra",
"huggingface",
"pytorch",
"onnx",
"gguf",
"safetensors",
]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: Developers",
"Intended Audience :: Information Technology",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Topic :: Security",
"Topic :: Software Development :: Quality Assurance",
"Topic :: System :: Software Distribution",
]
packages = [{include = "aisbom"}]
repository = "https://github.com/Lab700xOrg/aisbom"
urls = { "Homepage" = "https://www.aisbom.io/" }
Expand Down
89 changes: 89 additions & 0 deletions tests/test_packaging_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Guards for the discovery metadata that PyPI and GitHub index.

None of this is runtime behaviour, which is exactly why it needs a test: the
keywords, classifiers and README terms are invisible in every normal code
review and silently deleteable in a routine `pyproject.toml` edit. The cost of
losing them is not a crash, it is that the package stops turning up in the
searches buyers actually run.
"""

import re
import tomllib
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent


def _poetry_table() -> dict:
with (REPO_ROOT / "pyproject.toml").open("rb") as fh:
return tomllib.load(fh)["tool"]["poetry"]


# The high-intent terms from PLAN-2026-08-08 Part 1.4, in PyPI keyword form.
TARGET_KEYWORDS = {
"aibom",
"ai-bom",
"mlbom",
"model-scanning",
"eu-ai-act",
"cra",
"mlsecops",
"sbom",
"cyclonedx",
"spdx",
}

# The same terms in the human-readable form the README uses.
TARGET_README_TERMS = (
"AIBOM",
"ML-BOM",
"MLSecOps",
"EU AI Act",
"Annex XI",
"Cyber Resilience Act",
"524B",
)


def test_license_is_declared_and_matches_the_license_file():
assert _poetry_table()["license"] == "Apache-2.0"
assert "Apache License" in (REPO_ROOT / "LICENSE").read_text()


def test_keywords_cover_the_target_search_terms():
keywords = _poetry_table()["keywords"]
assert TARGET_KEYWORDS <= set(keywords), TARGET_KEYWORDS - set(keywords)


def test_keywords_are_normalised_and_unique():
keywords = _poetry_table()["keywords"]
assert len(keywords) == len(set(keywords)), "duplicate keyword"
for keyword in keywords:
assert keyword == keyword.strip().lower(), keyword
assert re.fullmatch(r"[a-z0-9][a-z0-9-]*", keyword), keyword


def test_classifiers_are_well_formed_and_carry_the_key_facets():
classifiers = _poetry_table()["classifiers"]
for classifier in classifiers:
# PyPI rejects the whole upload on a malformed classifier, so a typo
# here is a release-time failure, not a cosmetic one.
assert " :: " in classifier, classifier
assert classifier == classifier.strip(), classifier
assert "License :: OSI Approved :: Apache Software License" in classifiers
assert "Topic :: Security" in classifiers
assert "Topic :: Scientific/Engineering :: Artificial Intelligence" in classifiers


def test_summary_describes_the_tool_without_duplicated_words():
description = _poetry_table()["description"]
assert 40 < len(description) <= 300, len(description)
words = re.findall(r"\b\w+\b", description.lower())
repeats = [a for a, b in zip(words, words[1:]) if a == b]
assert not repeats, f"duplicated word(s) in summary: {repeats}"


def test_readme_covers_the_target_terms():
readme = (REPO_ROOT / "README.md").read_text()
missing = [term for term in TARGET_README_TERMS if term not in readme]
assert not missing, missing
34 changes: 31 additions & 3 deletions tests/test_scanner_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,10 +170,38 @@ def test_scan_footer_nudges_share_when_flag_not_used(tmp_path, monkeypatch):


def _write_malicious_pt(path: Path):
"""Create a PyTorch-style archive with a known dangerous pickle payload."""
"""Create a PyTorch-style archive with a known dangerous pickle payload.

Entry timestamps are pinned. `writestr` with a plain name stamps each entry
with the current local time at the DOS format's 2-second granularity, so
writing this fixture twice produced different bytes — and therefore a
different SHA-256 — whenever the two writes straddled a 2-second boundary.
Any test that regenerates the artifact and compares hashes across two scans
was a coin flip on how long the first scan took.
"""
entries = (("archive/data.pkl", STACK_GLOBAL_SYSTEM), ("archive/version", "3"))
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("archive/data.pkl", STACK_GLOBAL_SYSTEM)
zf.writestr("archive/version", "3")
for name, payload in entries:
info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_DEFLATED
zf.writestr(info, payload)


def test_malicious_pt_fixture_is_reproducible(tmp_path):
"""Regression: the fixture embedded wall-clock timestamps.

`test_default_scan_preserves_every_1_6_component_field` regenerates this
artifact before each of its two scans and then asserts the component
hashes match, so a time-dependent fixture made that test fail roughly
whenever the first scan crossed a 2-second boundary.
"""
first, second = tmp_path / "first.pt", tmp_path / "second.pt"
_write_malicious_pt(first)
_write_malicious_pt(second)

assert first.read_bytes() == second.read_bytes()
with zipfile.ZipFile(first) as zf:
assert {i.date_time for i in zf.infolist()} == {(1980, 1, 1, 0, 0, 0)}


def test_scan_pickle_stream_detects_dangerous_opcode():
Expand Down