feat(modelcard): give local model files the modelCard of the Hugging Face repo they came from - #114
Conversation
…Face repo they came from A local copy of a Hugging Face model used to get no modelCard, while an hf:// scan of the same repo did. Local scans now look for evidence of the repo (the HF cache layout, or a config.json whose _name_or_path is an org/name repo id), prove it by finding the file's SHA-256 or git blob id in that repo's file listing, and only then attach the repo's card. - No proof, a lookup failure, or more than one repo proving out: no card. - A matched card records aisbom:hf:match (hf-cache or config-name-or-path). - Only the repo id and commit are sent; paths, names and hashes stay local. Path-like or single-name _name_or_path values are never requested. - On by default for CycloneDX 1.7, SPDX 3.0 and score on a directory; --no-hf-lookup, AISBOM_NO_HF_LOOKUP=1 or --offline turn it off. - SPDX 3.0 now links each model only to its own card's datasets. - README privacy section, Action README and air-gapped guide updated.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c519ec516d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| for dep in results.get("dependencies", []) | ||
| ], | ||
| "datasets": _card_datasets(results.get("hf_model_card")), | ||
| "datasets": _all_card_datasets(results), |
There was a problem hiding this comment.
Include per-model dataset assignments in the namespace
When the same artifacts and dependencies have different per-model dataset assignments but the same first-seen dataset union—for example, model A changes from [x, y] to [x] while model B changes from [z] to [y, z]—this fingerprint remains identical even though the generated trainedOn relationships differ. Both SPDX documents consequently reuse the same document, package, and relationship IRIs, so importing them into one SPDX/RDF store can conflate distinct relationship versions. Fingerprint each artifact's own _card_datasets(_card_for(...)) list rather than only the union.
Useful? React with 👍 / 👎.
… namespace Two scans could share a dataset union while assigning it differently between models (A [x, y]/B [z] versus A [x]/B [y, z]). The content-derived namespace hashed only the union, so both produced the same document, package and relationship IRIs while their trainedOn relationships differed, and a store importing both could conflate them. A matched model now carries its own dataset list in its fingerprint entry. Only matched artifacts carry it, so hf:// scans and local scans that matched nothing keep the IRIs they already have — asserted against a namespace produced by the released generator.
|
Codex P2 (per-model dataset assignments in the SPDX 3.0 namespace): fixed in 730d3d6. Reproduced first: A Only matched artifacts carry the extra element, so |
…risk artifacts (#131) (#115) * fix(scanner): report unreadable model files as errors, not clean LOW-risk artifacts (#131) `LOW` is an assertion: AIsbom opened the artifact and found nothing dangerous. For a file no parser could read, the honest answer is different — and collapsing the two meant a truncated download passed a CI gate as a clean model. Five inspectors already recorded their own parse failure in `meta["error"]` and nothing in the codebase ever read it. Two corruption shapes never raise at all (a text `.pt`, a two-byte pickle), and a git-LFS pointer set that key to the empty string, so a truthiness check would still have missed it. Each inspector now reports whether its parse actually succeeded. Unreadable files land in `results["errors"]` with a closed-set subtype (LfsPointer, EmptyFile, TruncatedStream, UnrecognizedFormat), print in their own "Could not read" section, and exit 1 — which is what the README's exit-code table has always documented for "a file failed to parse". The component stays in the SBOM so an auditor sees the file was present, but carries no framework label and no risk verdict, marked with `aisbom:unreadable` properties. Dropping the label is what stops `aisbom:format` claiming 200 random bytes are SafeTensors. `aisbom score` refuses to grade such a scan via the existing #114 gate. Also fixed, as the same class of problem: - The three `UNKNOWN (...)` verdicts (GGUF invalid header, Keras unrecognized container, unparsable ONNX) were honest but scored 0 in `_risk_score` — below LOW — so those files exited 0 and rated safer than a clean model. - Any text file in `.pt`/`.pth`/`.bin` was classified as a Python path config, so an HTML error page saved over a checkpoint scored LOW. That classification is now `.pth`-only and validated against the format's actual spec. - A SafeTensors header longer than the file is rejected before the read, replacing an internal OverflowError with a message naming the problem. A real-tree probe drove one design decision: requiring the pickle opcode walk to reach STOP flagged 75 valid files — every `.pkl` in joblib's own test corpus — because joblib appends array data as raw bytes after STOP. The rule is "nothing past the protocol header disassembled" instead, and that file shape is now a regression test. The probe over 35,283 artifacts (141 real `.pth` files, a node_modules tree, an HF cache, a generated valid-model corpus) reports zero false positives. BREAKING: a tree containing unreadable files now exits 1 where it exited 0. Called out at the top of the changelog entry and in a README upgrade note. * fix(scanner): apply unreadable handling to remote scans and caught parse errors (#131) Addresses all three findings from the Codex review on PR #115. P1 — remote artifacts skipped the fix entirely. Every unreadable recording was guarded on `local_path is not None`, so `hf://` and HTTPS scans — the flagship "verify before you `git clone`" path — kept the old behaviour: a remote `.safetensors` of random bytes came back `framework="SafeTensors"`, `risk_level="LOW"` and exit 0. Each inspector now finalizes through one shared `_finalize_inspection`, identified by `local_path or name`, so local and remote are reported identically. The git-LFS subtype survives on the remote path too, which is a real case rather than a contrived one: `raw.githubusercontent.com` serves the pointer text, not the file, for anything LFS-tracked. The head is re-read only after a parse has already failed, so the extra Range request is never paid by a healthy file. P2 — a caught inspector exception stayed a clean verdict. All five inspectors wrap their body in `except Exception: meta["error"] = ...`, and nothing read that key; GGUF, Keras and ONNX seed `risk_level` to LOW, so a file that threw mid-parse exited 0 as clean. `_finalize_inspection` now picks that up — by key membership, not truthiness, because the LFS stub sets it to the empty string. A verdict already reached is never overwritten: an inspector can find a CRITICAL payload and then throw, and downgrading that to "unreadable" would lose the finding — the one outcome worse than the bug being fixed. Only the seeded UNKNOWN/LOW placeholders give way. Pinned by test. P2 — `.pth` path entries may contain spaces. `site.addpackage` only rstrips a line before joining it, so `/opt/My Models/site-packages` and the Windows `C:\Program Files\...` shape are valid and were being called corrupt. Rejecting only control characters (as suggested) would have let `totally not a model at all` read as a path and undone the fix, so a line carrying whitespace must additionally hold a path separator: prose has none, a directory path does. The 141-file probe corpus contained no spaced path, which is why the real-tree probe could not catch this. Verification: 1498 tests pass (+8), coverage 93.79%. Real-tree probe re-run after the `.pth` change — still zero false positives, 141 `.pth` files kept clean. Scorecard, full-repo scan and `--strict` gates all exit 0.
What
A local copy of a Hugging Face model now gets the same
modelCardanhf://scan of its repo produces, when the link can be proven.models--<org>--<name>/snapshots/<commit>/…, which gives the repo and revision), or aconfig.jsonnext to the weights whose_name_or_pathis anorg/namerepo id. File names are never used.lfs.oidin the repo's file listing, or, for small files stored in git, its git blob id must equal anoid. A renamed file still matches. A fine-tune whose config still names its base model does not.hf://card fetch.A matched card carries
aisbom:hf:match(hf-cacheorconfig-name-or-path), so an inferred link can be told apart from anhf://scan.Privacy and opt-out
huggingface.co. The hash comparison happens locally, so paths, file names and hashes are never sent._name_or_paththat is a single name, looks like a path, or names a directory that exists is never requested.scoreon a directory.--no-hf-lookup,AISBOM_NO_HF_LOOKUP=1or--offlineturn it off.Other changes
hf://scans, where every model shares one card).score's model-card and dataset fix hints mention that a local copy in the HF cache also works.Verification
poetry run pytest --cov=aisbom --cov-fail-under=85: 1452 passed, 93.73% coverage.aisbom bypass-scorecard --checkpasses.hf-internal-testing/tiny-random-bert, comparingmainagainst this branch:model.safetensorsvia git blob id,pytorch_model.binvia LFS SHA-256). No field regressions; the only added component key ismodelCard, and it equals thehf://scan's card apart fromaisbom:hf:match.mainapart from serial number and timestamp.config.jsonnaming the repo: matched. Fine-tune with a config naming the base model: no card.aisbom scoreon the cache copy: 50.0 (D) with--no-hf-lookup, 65.0 (C) without.Release note
Local scans of HF-cached models now contact
huggingface.coby default, including in the GitHub Action when the scanned directory has that evidence.