From eb809f69d298a4ba7a523d3271359fa25bce87df Mon Sep 17 00:00:00 2001 From: Ajoy L Date: Mon, 7 Sep 2026 20:54:03 -0500 Subject: [PATCH 1/2] chore(meta): add PyPI discovery metadata and regulatory keyword coverage The package shipped with no keywords, no classifiers and no license field, so the PyPI page carried zero search facets and no license signal despite the Apache-2.0 LICENSE on disk. Add all three, plus a guard test, since none of this is runtime behaviour and all of it is silently deleteable in a routine pyproject edit. - pyproject: license Apache-2.0, 22 keywords, 15 classifiers - pyproject: rewrite the summary, which read "a tool that that detects" - README: PyPI downloads badge, and a regulatory-risk bullet covering EU AI Act Article 53 / Annex XI, the EU Cyber Resilience Act and FDA 524B, framed as evidence rather than as a compliance verdict - tests: assert the keywords, classifiers, license and README terms hold No runtime code is touched. Refs #122. --- README.md | 2 + pyproject.toml | 48 ++++++++++++++++- tests/test_packaging_metadata.py | 89 ++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 tests/test_packaging_metadata.py 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 From 4b3ca6fdb2c8c2aed677e1005364c9c24a2f1188 Mon Sep 17 00:00:00 2001 From: Ajoy L Date: Mon, 7 Sep 2026 20:58:00 -0500 Subject: [PATCH 2/2] test: pin fixture zip timestamps so repeat writes are byte-identical MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_write_malicious_pt` called `writestr` with a plain entry name, which stamps each entry with the current local time at the DOS format's 2-second granularity. `test_default_scan_preserves_every_1_6_component_field` regenerates the artifact before each of its two scans and then asserts the component hashes match, so it failed whenever the first scan happened to cross a 2-second boundary — a coin flip, latent since the test was written, and unrelated to whatever change is on the branch when it fires. Pin the entry timestamps and add a regression test asserting the fixture is reproducible. Verified by writing the fixture twice 2.1s apart: identical SHA-256 with the fix, different without it. --- tests/test_scanner_cli.py | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) 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():