diff --git a/README.md b/README.md index ab778f7..52f553c 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. --- diff --git a/pyproject.toml b/pyproject.toml index 3a652cc..b0daa1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "] 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/" } diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py new file mode 100644 index 0000000..9819aac --- /dev/null +++ b/tests/test_packaging_metadata.py @@ -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 diff --git a/tests/test_scanner_cli.py b/tests/test_scanner_cli.py index a92d273..9de41d9 100644 --- a/tests/test_scanner_cli.py +++ b/tests/test_scanner_cli.py @@ -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():